Compare commits

...

118 Commits

Author SHA1 Message Date
Tom Moor 0cb82f9349 attempt 2 – set explicit charset tag 2021-01-15 18:36:24 -08:00
Tom Moor 7b7c1c0bc2 possible fix 2021-01-15 18:24:15 -08:00
Tom Moor 1fd2ec31fd fix: Heading positioning changing between edit/read-only
fix: List items beyond #9 chopped
2021-01-15 08:50:19 -08:00
Tom Moor 1af00a0b3d test 2021-01-14 20:15:46 -08:00
Tom Moor ab40545a01 lint 2021-01-14 20:11:04 -08:00
Tom Moor c8d305aeca fix: Unintended scroll reset when switching between view / edit (#1807)
* fix: Don't remount document when switching between edit/read-only
fix: Button vertical alignment when using as=Link

* fix: Bump RME, fixes issue with image behavior changing between read-only/edit without editor remount

* fix: Heading anchor positioning
2021-01-14 19:50:10 -08:00
Maximilian Zinke 68a65be135 feat: Embed Google Drive (#1804)
* implement google drive extension

* add current logo of google drive

* fix issue when posting gdrive links which are already a preview

* always only show the preview

* Add bottom bar to get to original url for Google Drive embeds

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-01-14 19:49:56 -08:00
Tom Moor b4d307b3b4 fix: Confusing breadcrumb collapsing 2021-01-14 19:36:31 -08:00
Tom Moor 03cb6d66e7 fix: Alignment of collection icon in header when collection name is very long 2021-01-14 18:59:43 -08:00
Tom Moor 7b8cbc50d5 fix: Document meta unclickable when first item in a document is a heading 2021-01-14 18:51:58 -08:00
Tom Moor f501da9c0f flow 2021-01-14 18:38:02 -08:00
Translate-O-Tron 74a762a7c7 New Crowdin updates (#1790) 2021-01-14 09:08:38 -08:00
Rubén Moya 93ac9892d5 fix: take into account user lang in Time component (#1793)
This PR takes into account the user selected language to format the time in the Time component.

Co-authored-by: tommoor <tom.moor@gmail.com>
2021-01-14 09:08:14 -08:00
Tom Moor e8b7782f5e fix: Keyboard accessible context menus (#1768)
- Makes menus fully accessible and keyboard driven
- Currently adds 2.8% to initial bundle size due to the inclusion of Reakit and its dependency, popperjs.
- Converts all menus to functional components
- Remove old custom menu system
- Various layout and flow improvements around the menus

closes #1766
2021-01-13 22:00:25 -08:00
Rubén Moya 47369dd968 chore: rename collection creatorId to createdById (#1794) 2021-01-11 23:17:31 -08:00
Tom Moor d258082c5f lint 2021-01-11 18:25:21 -08:00
Tom Moor c0bbae50c4 fix: Search results not updated when changing filters 2021-01-11 00:50:44 -08:00
Tom Moor ac082e4a5f Merge branch 'develop' of github.com:outline/outline into develop 2021-01-11 00:47:48 -08:00
Tom Moor 7504d43452 fix: Add indicator of starred status when viewing a document (#1785)
* fix: Add indicator of starred status when viewing a document
closes #461

* fix: Account for shared document
2021-01-10 23:13:58 -08:00
Tom Moor 5dba68dfd3 fix: Incorrect border color on table of contents in dark mode 2021-01-07 23:50:28 -08:00
Tom Moor 4b85603f30 chore: Text highlight blue -> yellow 2021-01-07 23:25:14 -08:00
Tom Moor 34598b317d fix: Deleting a collection should not deleted archived documents within it automatially (#1776)
closes #1775
2021-01-07 19:46:12 -08:00
Translate-O-Tron cbfa25fa2f New Crowdin updates (#1736)
* fix: New Japanese translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2021-01-07 08:09:44 -08:00
Tom Moor 67a2246e1a fix: Attempting to restore document in deleted collection without a collectionId override results in server error (#1777)
closes #1767
2021-01-07 08:09:19 -08:00
Tom Moor de7bf8c133 fix: Fixes padding on sidebar collection links
closes #1770
2021-01-06 20:35:02 -08:00
Tom Moor 4fda50f4ad feat: Add 'archive' option in delete confirmation modal (#1764)
* feat: Add 'archive' option in delete confirmation modal
chore: Add translation tags to delete confirmation

* i18n

* language
2021-01-03 11:04:09 -08:00
Tom Moor f4c5cc054e chore: Update sorting icons 2021-01-03 09:38:29 -08:00
Tom Moor f799758a6f feat: Allow Google sign-in users to choose account
Alternative to https://github.com/outline/outline/pull/1763
2021-01-03 08:54:47 -08:00
Tom Moor 9df02d6fd4 chore: Improve toasts 2021-01-02 21:47:02 -08:00
Tom Moor bb81aa0065 fix: Improve toast messages to not show multiple of the same 2021-01-02 21:09:43 -08:00
Tom Moor 68bbd9fa34 fix: Hold hover state on DocumentListItem while DocumentMenu is open 2021-01-02 20:02:57 -08:00
Tom Moor 308d4bd797 i18n 2021-01-02 19:19:45 -08:00
Tom Moor 5329474c85 fix: Developer warning batchingForReactDom 2021-01-02 19:13:11 -08:00
Tom Moor d90af48741 fix: Outer error boundary generates more errors as it doesnt have access to store and theme providers 2021-01-02 19:12:51 -08:00
Tom Moor 611e9b97b3 chore: Move collection sort control (#1762)
* feat: Move collection sort menu

* fix: Improve accessibility

* fix: Dedupe outline-icons (temporary until rme is next merged)
2021-01-02 19:11:13 -08:00
Nan Yu eda5adca2c feat: adds hover to expand functionality on sidebar (#1761)
* feat: adds hover to expand functionality on sidebar

* clear hover intent timeout on drag leave
2021-01-02 17:20:13 -08:00
Tom Moor f0b361158e flow 2021-01-02 09:09:06 -08:00
Tom Moor f8ab793053 fix: 'New' badge should never show to document creator, regardless of whether a view has been logged (#1758) 2020-12-31 16:46:33 -08:00
Nan Yu 2cc45187e6 feat: reordering documents in collection (#1722)
* tweaking effect details

* wrap work on this feature

* adds correct color to drop cursor

* simplify logic for early return

* much better comment so Tom doesn't fire me

* feat: Allow changing sort order of collections

* refactor: Move validation to model
feat: Make custom order the default (in prep for dnd)

* feat: Add sort choice to edit collection modal
fix: Improved styling of generic InputSelect

* fix: Vertical space left after removing previous collection description

* chore: Tweak language, menu contents, add auto-disclosure on sub menus

* only show drop-to-reorder cursor when sort is set to manual

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2020-12-31 12:51:12 -08:00
Tom Moor ba61091c4c fix: Allow soft deletion of teams (#1754)
* fix: Allow soft deletion of teams

* test: regression specs
2020-12-30 09:40:23 -08:00
Tom Moor 8dba32b5e0 fix: Meta key shortcuts not bound correctly in Windows browsers (#1753) 2020-12-30 09:35:33 -08:00
Clifton Cunningham 40bd9aed0a fix: miro - use the incoming domain to ensure access to logged in boards works (#1756) 2020-12-30 09:35:18 -08:00
Tom Moor d4bb04e921 fix: Handle linked documents destroyed when document is published
closes #1739
2020-12-29 10:32:09 -08:00
Nan Yu 8a3a279c0e Merge branch 'develop' of github.com:outline/outline into develop 2020-12-28 21:35:37 -08:00
Nan Yu 37f2cc8d55 closes #1752 2020-12-28 21:35:13 -08:00
Gustavo Maronato 89903b4bbe feat: Compress avatar images before upload (#1751)
* compress avatar images before upload

* move compressImage to dedicated file

* Update ImageUpload.js
2020-12-28 21:08:10 -08:00
Malek Hijazi b6ab816bb3 feat: command to upgrade outline (#1727)
* Add upgrade script to package.json

* Update the docs to include docker and yarn guides
2020-12-25 15:23:55 -08:00
Tom Moor ac1120914a fix: Unable to delete archived and templated documents (#1749)
closes #1746
2020-12-24 13:28:08 -08:00
Tom Moor ea57cef89c fix: Reduce double reporting of errors 2020-12-21 21:10:25 -08:00
Translate-O-Tron 7d44e1aeeb New Crowdin updates (#1725)
* fix: New Japanese translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Spanish translations from Crowdin [ci skip]
2020-12-21 19:28:41 -08:00
Tom Moor 25d5ad8a7e chore: Enable automatic generation of email server in non production environments (#1731) 2020-12-21 19:27:14 -08:00
dependabot[bot] e34ba1457e chore(deps): bump node-notifier from 8.0.0 to 8.0.1 (#1734)
Bumps [node-notifier](https://github.com/mikaelbr/node-notifier) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/mikaelbr/node-notifier/releases)
- [Changelog](https://github.com/mikaelbr/node-notifier/blob/v8.0.1/CHANGELOG.md)
- [Commits](https://github.com/mikaelbr/node-notifier/compare/v8.0.0...v8.0.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-21 19:26:12 -08:00
Tom Moor e966eb8c9a fix: Error notice not displayed to user when exceeding rate limit on signin attempt 2020-12-20 13:05:16 -08:00
Tom Moor 4684b3a3f3 fix: Server error when invalid JSON passed to API endpoint
Fix is to ensure that the errorHandling middleware is mounted before the body parser so that it can catch and return an error response
2020-12-20 12:08:47 -08:00
Tom Moor 47ce8afcc5 fix: Server Error when requesting invalid locale 2020-12-20 11:53:09 -08:00
Tom Moor decbe4f643 fix: Allow deleting attachments not linked to documents when owned by user
closes #1729
2020-12-20 11:39:09 -08:00
Tom Moor 117d278d16 fix: Deprecated Buffer usage, closes #1726 2020-12-19 15:58:21 -08:00
Tom Moor 40ca73e684 feat: Collapsible sidebar (#1721)
* wip

* styling, add keyboard shortcut

* tweak styling
2020-12-17 22:26:04 -08:00
Nan Yu 051ecab0fc feat: Moving documents via drag and drop in sidebar (#1717)
* wip: added some basic drag and drop UI for combining items

* refactor: pathToDocument to accept only id

* fix: Multiple drop backends error
fix: Incorrect styling dragging over active collection
fix: Stay in disabled state until save is complete

* Improving display while moving doc

* fix: update by user should be changed when moving a doc

* add move guard to drag

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2020-12-15 19:07:29 -08:00
Tom Moor 3469b82beb feat: Add Korean as available language choice 2020-12-15 08:11:50 -08:00
Tom Moor f2c3481670 test 2020-12-14 23:04:39 -08:00
Tom Moor bc141dc40c New Crowdin updates (#1718)
* fix: New French translations from Crowdin [ci skip]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: New Korean translations from Crowdin [ci skip]
2020-12-14 22:28:47 -08:00
Translate-O-Tron 99814f6e2f fix: New Korean translations from Crowdin [ci skip] 2020-12-14 22:19:57 -08:00
Translate-O-Tron 3737f0b42c fix: New Portuguese translations from Crowdin [ci skip] 2020-12-14 22:19:53 -08:00
Translate-O-Tron 9ef27cb436 fix: New German translations from Crowdin [ci skip] 2020-12-14 22:19:50 -08:00
Translate-O-Tron c9fa3f93f2 fix: New Spanish translations from Crowdin [ci skip] 2020-12-14 22:19:48 -08:00
Translate-O-Tron 24ed96c9a5 fix: New French translations from Crowdin [ci skip] 2020-12-14 22:19:46 -08:00
Translate-O-Tron 956cf401bd fix: New Korean translations from Crowdin [ci skip] 2020-12-14 21:17:46 -08:00
Translate-O-Tron 7cb837f478 fix: New Portuguese, Brazilian translations from Crowdin [ci skip] 2020-12-14 21:17:44 -08:00
Translate-O-Tron a3209e9d23 fix: New Chinese Simplified translations from Crowdin [ci skip] 2020-12-14 21:17:43 -08:00
Translate-O-Tron 29582a1bb1 fix: New Russian translations from Crowdin [ci skip] 2020-12-14 21:17:41 -08:00
Translate-O-Tron 4084c91769 fix: New Portuguese translations from Crowdin [ci skip] 2020-12-14 21:17:39 -08:00
Translate-O-Tron 6772f28226 fix: New Japanese translations from Crowdin [ci skip] 2020-12-14 21:17:37 -08:00
Translate-O-Tron c6b110d339 fix: New German translations from Crowdin [ci skip] 2020-12-14 21:17:35 -08:00
Translate-O-Tron 0a43b50c66 fix: New Spanish translations from Crowdin [ci skip] 2020-12-14 21:17:33 -08:00
Translate-O-Tron 4a82cb0658 fix: New French translations from Crowdin [ci skip] 2020-12-14 21:17:31 -08:00
Tom Moor 2f7fca6106 chore: Move formatting out of translation strings 2020-12-14 21:16:02 -08:00
Translate-O-Tron 8f83cfef25 fix: New Korean translations from Crowdin [ci skip] 2020-12-14 20:39:04 -08:00
Tom Moor e2e66954b5 fix: Attachments should not always be deleted with their original document (#1715)
* fix: Attachments should not be deleted when their original document is deleted when referenced elsewhere

* fix: Attachments deleted prematurely when docs are placed in trash

* mock

* restore hook, cascading delete was the issue
2020-12-14 19:55:22 -08:00
Tom Moor 3dbe54ac1e fix: Bump RME, closes #1719 2020-12-14 19:18:46 -08:00
Translate-O-Tron 50577f6f2f fix: New Korean translations from Crowdin [ci skip] 2020-12-14 09:40:18 -08:00
Translate-O-Tron 16d504703d fix: New Korean translations from Crowdin [ci skip] 2020-12-14 08:42:26 -08:00
Translate-O-Tron 173febcaa1 fix: New Korean translations from Crowdin [ci skip] 2020-12-14 07:40:07 -08:00
Translate-O-Tron f92f4cde7a fix: New Korean translations from Crowdin [ci skip] 2020-12-14 06:45:01 -08:00
Translate-O-Tron 23bec75bd0 fix: New Korean translations from Crowdin [ci skip] 2020-12-14 05:49:49 -08:00
Translate-O-Tron 4dd667f68b fix: New Korean translations from Crowdin [ci skip] 2020-12-14 04:47:25 -08:00
Translate-O-Tron 4b3cb77cc7 fix: New Korean translations from Crowdin [ci skip] 2020-12-14 03:48:01 -08:00
Translate-O-Tron 0e83d54f93 fix: New German translations from Crowdin [ci skip] 2020-12-13 23:30:53 -08:00
Translate-O-Tron d867d9fea5 fix: New Spanish translations from Crowdin [ci skip] 2020-12-13 23:30:51 -08:00
Translate-O-Tron 28c8b8acfe fix: New French translations from Crowdin [ci skip] 2020-12-13 23:30:49 -08:00
Translate-O-Tron 51efffe2ce fix: New Korean translations from Crowdin [ci skip] 2020-12-13 22:34:18 -08:00
Tom Moor 4e9ee7249f Update LICENSE 2020-12-13 17:48:15 -08:00
Tom Moor 574fcc4bb3 0.51.0 2020-12-13 17:43:58 -08:00
Tom Moor 5c3000d5cf Bump RME, fixes table after list and image captions in Safari 2020-12-13 17:20:38 -08:00
Translate-O-Tron c0216cbb8d fix: New Portuguese, Brazilian translations from Crowdin [ci skip] 2020-12-12 22:40:33 -08:00
Translate-O-Tron cf12301077 fix: New Chinese Simplified translations from Crowdin [ci skip] 2020-12-12 22:40:31 -08:00
Translate-O-Tron 1eb7da8742 fix: New Russian translations from Crowdin [ci skip] 2020-12-12 22:40:29 -08:00
Translate-O-Tron b3c548382f fix: New Portuguese translations from Crowdin [ci skip] 2020-12-12 22:40:27 -08:00
Translate-O-Tron 7ebac53b43 fix: New Japanese translations from Crowdin [ci skip] 2020-12-12 22:40:25 -08:00
Translate-O-Tron 64428a6894 fix: New German translations from Crowdin [ci skip] 2020-12-12 22:40:23 -08:00
Translate-O-Tron d536af5269 fix: New Spanish translations from Crowdin [ci skip] 2020-12-12 22:40:22 -08:00
Translate-O-Tron 1726a88a60 fix: New French translations from Crowdin [ci skip] 2020-12-12 22:40:20 -08:00
Tom Moor 3fe807a10a fix: Object printed in UI 2020-12-12 22:29:20 -08:00
Tom Moor 72189e041b feat: attachments.delete (#1714)
* feat: Add endpoint for manually deleting attachments

* mock
2020-12-10 21:40:03 -08:00
Translate-O-Tron bc156f4cc8 New Crowdin updates (#1707)
* fix: New German translations from Crowdin [ci skip]

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

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

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

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

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

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]
2020-12-10 19:03:01 -08:00
dependabot[bot] 26693c60df chore(deps): bump ini from 1.3.5 to 1.3.7 (#1713)
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.7.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.7)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-10 18:43:50 -08:00
Nan Yu 9e1f31e14c fix: dropzone error on image upload component (#1711) 2020-12-08 19:32:41 -08:00
Nan Yu 63d926e196 slightly nicer color definitions (#1705) 2020-12-07 08:56:07 -08:00
Reid Beels 3f9f1f0bed docs: Add note to .env.sample about Google OAuth URI (#1706) 2020-12-07 08:55:37 -08:00
Tom Moor b2bdc7f1d4 chore: Add user and auth context to server side error reports (#1693) 2020-12-06 17:59:44 -08:00
Translate-O-Tron 2e798c698d chore: New Crowdin updates (#1691) 2020-12-06 17:54:16 -08:00
Nan Yu aa59f5fe09 chore: React-Dropzone version bump (#1699)
* update dropzone to new version

* remove global styles import

* change bg on active item on drag as well

* add back background
2020-12-06 17:50:59 -08:00
Tom Moor ac2060b166 fix: Migrate attachment columns to incease available length (#1704)
closes #1703
2020-12-06 16:51:25 -08:00
Tom Moor 424c29536d chore: Bump RME (SQL language support) 2020-12-04 10:18:30 -08:00
Tom Moor 6c1ecde4e7 fix: Server error when attempting to update team with identical details to previous 2020-12-04 10:18:30 -08:00
Tom Moor aa6fc45097 Add localization status to README 2020-12-04 08:22:43 -08:00
Tom Moor 9478944718 fix: Account for non-recorded views, closes #1700 2020-12-02 20:50:54 -08:00
Tom Moor 9e1c5d1db3 fix: JS error in UserProfile introduced in refactoring to functional component 2020-12-02 20:48:24 -08:00
186 changed files with 6393 additions and 3842 deletions
+6 -1
View File
@@ -18,12 +18,17 @@ PORT=3000
FORCE_HTTPS=true
ENABLE_UPDATES=true
DEBUG=cache,presenters,events
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# Third party signin credentials (at least one is required)
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
# To configure Google auth, you'll need to create an OAuth Client ID at
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<your Outline URL>/auth/google.callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
+1
View File
@@ -32,6 +32,7 @@ module.file_ext=.json
esproposal.decorators=ignore
esproposal.class_static_fields=enable
esproposal.class_instance_fields=enable
esproposal.optional_chaining=enable
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.50.0
Licensed Work: Outline 0.51.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2023-11-14
Change Date: 2023-12-13
Change License: Apache License, Version 2.0
+15
View File
@@ -12,6 +12,7 @@
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
<a href="https://translate.getoutline.com/project/outline"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
@@ -74,6 +75,20 @@ In development you can quickly get an environment running using Docker by follow
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
### Upgrade
#### Docker
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
```
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
```
#### Yarn
If you're running Outline by cloning this repository, run the following command to upgrade:
```
yarn upgrade
```
## Development
-5
View File
@@ -11,11 +11,6 @@ export const Action = styled(Flex)`
font-size: 15px;
flex-shrink: 0;
a {
color: ${(props) => props.theme.text};
height: 24px;
}
&:empty {
display: none;
}
+26 -34
View File
@@ -4,7 +4,6 @@ import {
ArchiveIcon,
EditIcon,
GoToIcon,
MoreIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
@@ -14,18 +13,15 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import CollectionsStore from "stores/CollectionsStore";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import BreadcrumbMenu from "./BreadcrumbMenu";
import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {
document: Document,
collections: CollectionsStore,
onlyText: boolean,
};
@@ -35,11 +31,11 @@ function Icon({ document }) {
if (document.isDeleted) {
return (
<>
<CollectionName to="/trash">
<CategoryName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CollectionName>
</CategoryName>
<Slash />
</>
);
@@ -47,11 +43,11 @@ function Icon({ document }) {
if (document.isArchived) {
return (
<>
<CollectionName to="/archive">
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CollectionName>
</CategoryName>
<Slash />
</>
);
@@ -59,11 +55,11 @@ function Icon({ document }) {
if (document.isDraft) {
return (
<>
<CollectionName to="/drafts">
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CollectionName>
</CategoryName>
<Slash />
</>
);
@@ -71,11 +67,11 @@ function Icon({ document }) {
if (document.isTemplate) {
return (
<>
<CollectionName to="/templates">
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CollectionName>
</CategoryName>
<Slash />
</>
);
@@ -89,8 +85,6 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
let collection = collections.get(document.collectionId);
if (!collection) {
if (!document.deletedAt) return <div />;
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
@@ -99,7 +93,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
}
const path = collection.pathToDocument
? collection.pathToDocument(document).slice(0, -1)
? collection.pathToDocument(document.id).slice(0, -1)
: [];
if (onlyText === true) {
@@ -135,7 +129,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
@@ -150,6 +144,11 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
);
};
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Wrapper = styled(Flex)`
display: none;
@@ -170,22 +169,6 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.25;
`;
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Overflow = styled(MoreIcon)`
flex-shrink: 0;
transition: opacity 100ms ease-in-out;
fill: ${(props) => props.theme.divider};
&:active,
&:hover {
fill: ${(props) => props.theme.text};
}
`;
const Crumb = styled(Link)`
color: ${(props) => props.theme.text};
font-size: 15px;
@@ -201,12 +184,21 @@ const Crumb = styled(Link)`
const CollectionName = styled(Link)`
display: flex;
flex-shrink: 0;
flex-shrink: 1;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`;
const CategoryName = styled(CollectionName)`
flex-shrink: 0;
`;
export default observer(Breadcrumb);
-22
View File
@@ -1,22 +0,0 @@
// @flow
import * as React from "react";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
type Props = {
label: React.Node,
path: Array<any>,
};
export default function BreadcrumbMenu({ label, path }: Props) {
return (
<DropdownMenu label={label} position="center">
<DropdownMenuItems
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</DropdownMenu>
);
}
+27 -15
View File
@@ -22,9 +22,13 @@ const RealButton = styled.button`
cursor: pointer;
user-select: none;
svg {
fill: ${(props) => props.iconColor || props.theme.buttonText};
}
${(props) =>
!props.borderOnHover &&
`
svg {
fill: ${props.iconColor || props.theme.buttonText};
}
`}
&::-moz-focus-inner {
padding: 0;
@@ -42,24 +46,30 @@ const RealButton = styled.button`
}
${(props) =>
props.neutral &&
props.$neutral &&
`
background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.buttonNeutralText};
box-shadow: ${
props.borderOnHover ? "none" : "rgba(0, 0, 0, 0.07) 0px 1px 2px"
};
border: 1px solid ${
props.borderOnHover ? "transparent" : props.theme.buttonNeutralBorder
props.borderOnHover
? "none"
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
};
svg {
${
props.borderOnHover
? ""
: `svg {
fill: ${props.iconColor || props.theme.buttonNeutralText};
}`
}
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
border: 1px solid ${props.theme.buttonNeutralBorder};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
}
&:disabled {
@@ -71,9 +81,9 @@ const RealButton = styled.button`
background: ${props.theme.danger};
color: ${props.theme.white};
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
`};
`;
@@ -92,7 +102,7 @@ export const Inner = styled.span`
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 30px;
min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
@@ -107,6 +117,7 @@ export type Props = {
children?: React.Node,
innerRef?: React.ElementRef<any>,
disclosure?: boolean,
neutral?: boolean,
fullwidth?: boolean,
borderOnHover?: boolean,
};
@@ -118,13 +129,14 @@ function Button({
value,
disclosure,
innerRef,
neutral,
...rest
}: Props) {
const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined;
return (
<RealButton type={type} ref={innerRef} {...rest}>
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
+13
View File
@@ -0,0 +1,13 @@
// @flow
import styled from "styled-components";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -1,6 +1,7 @@
// @flow
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components";
type Props = {
@@ -8,43 +9,51 @@ type Props = {
children?: React.Node,
selected?: boolean,
disabled?: boolean,
as?: string | React.ComponentType<*>,
};
const DropdownMenuItem = ({
const MenuItem = ({
onClick,
children,
selected,
disabled,
as,
...rest
}: Props) => {
return (
<MenuItem
<BaseMenuItem
onClick={disabled ? undefined : onClick}
disabled={disabled}
role="menuitem"
tabIndex="-1"
{...rest}
>
{selected !== undefined && (
<>
<CheckmarkIcon
color={selected === false ? "transparent" : undefined}
/>
&nbsp;
</>
{(props) => (
<MenuAnchor as={onClick ? "button" : as} {...props}>
{selected !== undefined && (
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
)}
{children}
</MenuAnchor>
)}
{children}
</MenuItem>
</BaseMenuItem>
);
};
const MenuItem = styled.a`
const Spacer = styled.div`
width: 24px;
height: 24px;
`;
export const MenuAnchor = styled.a`
display: flex;
margin: 0;
border: 0;
padding: 6px 12px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
@@ -58,6 +67,7 @@ const MenuItem = styled.a`
}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
@@ -66,7 +76,8 @@ const MenuItem = styled.a`
? "pointer-events: none;"
: `
&:hover {
&:hover,
&.focus-visible {
color: ${props.theme.white};
background: ${props.theme.primary};
box-shadow: none;
@@ -84,4 +95,4 @@ const MenuItem = styled.a`
`};
`;
export default DropdownMenuItem;
export default MenuItem;
@@ -0,0 +1,21 @@
// @flow
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import NudeButton from "components/NudeButton";
export default function OverflowMenuButton({
iconColor,
className,
...rest
}: any) {
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<MoreIcon color={iconColor} />
</NudeButton>
)}
</MenuButton>
);
}
+16
View File
@@ -0,0 +1,16 @@
// @flow
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: {}) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 0.5em 12px;
`;
@@ -1,26 +1,38 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import DropdownMenu from "./DropdownMenu";
import DropdownMenuItem from "./DropdownMenuItem";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
type MenuItem =
type TMenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
@@ -29,7 +41,7 @@ type MenuItem =
disabled?: boolean,
style?: Object,
hover?: boolean,
items: MenuItem[],
items: TMenuItem[],
|}
| {|
type: "separator",
@@ -42,10 +54,35 @@ type MenuItem =
|};
type Props = {|
items: MenuItem[],
items: TMenuItem[],
|};
export default function DropdownMenuItems({ items }: Props): React.Node {
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
justify-self: flex-end;
`;
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor {...props}>
{title} <Disclosure color="currentColor" />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -66,63 +103,67 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
return filtered.map((item, index) => {
if (item.to) {
return (
<DropdownMenuItem
<MenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
selected={item.selected}
{...menu}
>
{item.title}
</DropdownMenuItem>
</MenuItem>
);
}
if (item.href) {
return (
<DropdownMenuItem
<MenuItem
href={item.href}
key={index}
disabled={item.disabled}
selected={item.selected}
target="_blank"
{...menu}
>
{item.title}
</DropdownMenuItem>
</MenuItem>
);
}
if (item.onClick) {
return (
<DropdownMenuItem
<MenuItem
as="button"
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
key={index}
{...menu}
>
{item.title}
</DropdownMenuItem>
</MenuItem>
);
}
if (item.items) {
return (
<DropdownMenu
style={item.style}
label={
<DropdownMenuItem disabled={item.disabled}>
{item.title}
</DropdownMenuItem>
}
hover={item.hover}
<BaseMenuItem
key={index}
>
<DropdownMenuItems items={item.items} />
</DropdownMenu>
as={Submenu}
templateItems={item.items}
title={item.title}
{...menu}
/>
);
}
if (item.type === "separator") {
return <hr key={index} />;
return <Separator key={index} />;
}
return null;
});
}
export default React.memo<Props>(Template);
+77
View File
@@ -0,0 +1,77 @@
// @flow
import { rgba } from "polished";
import * as React from "react";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
onClose?: () => void,
|};
export default function ContextMenu({
children,
onOpen,
onClose,
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
}
}, [onOpen, onClose, previousVisible, rest.visible]);
return (
<Menu {...rest}>
{(props) => (
<Position {...props}>
<Background>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
);
}
const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
`;
const Background = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
max-width: 276px;
box-shadow: ${(props) => props.theme.menuShadow};
pointer-events: all;
font-weight: normal;
@media print {
display: none;
}
`;
@@ -1,6 +1,5 @@
// @flow
import format from "date-fns/format";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -45,9 +44,7 @@ class RevisionListItem extends React.Component<Props> {
<StyledRevisionMenu
document={document}
revision={revision}
label={
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>
+2 -2
View File
@@ -2,7 +2,7 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import * as React from "react";
import Document from "models/Document";
import DocumentPreview from "components/DocumentPreview";
import DocumentListItem from "components/DocumentListItem";
type Props = {
documents: Document[],
@@ -18,7 +18,7 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
defaultActiveChildIndex={0}
>
{items.map((document) => (
<DocumentPreview key={document.id} document={document} {...rest} />
<DocumentListItem key={document.id} document={document} {...rest} />
))}
</ArrowKeyNavigation>
);
+240
View File
@@ -0,0 +1,240 @@
// @flow
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document";
import Badge from "components/Badge";
import Button from "components/Button";
import DocumentMeta from "components/DocumentMeta";
import EventBoundary from "components/EventBoundary";
import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useCurrentUser from "hooks/useCurrentUser";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
highlight?: ?string,
context?: ?string,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(props: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
showCollection,
showPublished,
showPin,
showDraft = true,
showTemplate,
highlight,
context,
} = props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
return (
<DocumentLink
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: document.url,
state: { title: document.titleWithDefault },
}}
>
<Content>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</Content>
<Actions>
{document.isTemplate && !document.isArchived && !document.isDeleted && (
<>
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
<DocumentMenu
document={document}
showPin={showPin}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
modal={false}
/>
</Actions>
</DocumentLink>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const Actions = styled(EventBoundary)`
display: none;
align-items: center;
margin: 8px;
flex-shrink: 0;
flex-grow: 0;
${breakpoint("tablet")`
display: flex;
`};
`;
const DocumentLink = styled(Link)`
display: flex;
align-items: center;
margin: 10px -8px;
padding: 6px 8px;
border-radius: 8px;
max-height: 50vh;
min-width: 100%;
max-width: calc(100vw - 40px);
${Actions} {
opacity: 0;
}
${AnimatedStar} {
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
}
&:hover,
&:active,
&:focus,
&:focus-within {
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
${AnimatedStar} {
opacity: 0.5;
&:hover {
opacity: 1;
}
}
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
${AnimatedStar} {
opacity: 0.5;
}
`}
`;
const Heading = styled.h3`
display: flex;
align-items: center;
height: 24px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${(props) => props.theme.text};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
const StarPositioner = styled(Flex)`
margin-left: 4px;
align-items: center;
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${(props) => props.theme.textTertiary};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
`;
export default observer(DocumentListItem);
+1
View File
@@ -15,6 +15,7 @@ const Container = styled(Flex)`
font-size: 13px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const Modified = styled.span`
+2
View File
@@ -35,6 +35,8 @@ function DocumentMetaWithViews({ to, isDraft, document }: Props) {
const Meta = styled(DocumentMeta)`
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
z-index: 1;
a {
color: inherit;
@@ -1,247 +0,0 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { StarredIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Link, Redirect } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Badge from "components/Badge";
import Button from "components/Button";
import DocumentMeta from "components/DocumentMeta";
import EventBoundary from "components/EventBoundary";
import Flex from "components/Flex";
import Highlight from "components/Highlight";
import Tooltip from "components/Tooltip";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
document: Document,
highlight?: ?string,
context?: ?string,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
t: TFunction,
};
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();
this.props.document.star();
};
handleUnstar = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.props.document.unstar();
};
replaceResultMarks = (tag: string) => {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
};
handleNewFromTemplate = (event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
templateId: document.id,
});
};
render() {
const {
document,
showCollection,
showPublished,
showPin,
showDraft = true,
showTemplate,
highlight,
context,
t,
} = this.props;
if (this.redirectTo) {
return <Redirect to={this.redirectTo} push />;
}
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
return (
<DocumentLink
to={{
pathname: document.url,
state: { title: document.titleWithDefault },
}}
>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && <Badge yellow>{t("New")}</Badge>}
{!document.isDraft &&
!document.isArchived &&
!document.isTemplate && (
<Actions>
{document.isStarred ? (
<StyledStar onClick={this.handleUnstar} solid />
) : (
<StyledStar onClick={this.handleStar} />
)}
</Actions>
)}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
<SecondaryActions>
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted && (
<Button
onClick={this.handleNewFromTemplate}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
)}
&nbsp;
<EventBoundary>
<DocumentMenu document={document} showPin={showPin} />
</EventBoundary>
</SecondaryActions>
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={this.replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</DocumentLink>
);
}
}
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
<StarredIcon color={theme.text} {...props} />
))`
flex-shrink: 0;
opacity: ${(props) => (props.solid ? "1 !important" : 0)};
transition: all 100ms ease-in-out;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
`);
const SecondaryActions = styled(Flex)`
align-items: center;
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
`;
const DocumentLink = styled(Link)`
display: block;
margin: 10px -8px;
padding: 6px 8px;
border-radius: 8px;
max-height: 50vh;
min-width: 100%;
max-width: calc(100vw - 40px);
overflow: hidden;
position: relative;
${SecondaryActions} {
opacity: 0;
}
&:hover,
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
${SecondaryActions} {
opacity: 1;
}
${StyledStar} {
opacity: 0.5;
&:hover {
opacity: 1;
}
}
}
`;
const Heading = styled.h3`
display: flex;
align-items: center;
height: 24px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${(props) => props.theme.text};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
const Actions = styled(Flex)`
margin-left: 4px;
align-items: center;
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${(props) => props.theme.textTertiary};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
`;
export default withTranslation()<DocumentPreview>(DocumentPreview);
-3
View File
@@ -1,3 +0,0 @@
// @flow
import DocumentPreview from "./DocumentPreview";
export default DocumentPreview;
+39 -33
View File
@@ -5,7 +5,7 @@ import { observer, inject } from "mobx-react";
import * as React from "react";
import Dropzone from "react-dropzone";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import { createGlobalStyle } from "styled-components";
import styled, { css } from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import LoadingIndicator from "components/LoadingIndicator";
@@ -17,8 +17,6 @@ type Props = {
children: React.Node,
collectionId: string,
documentId?: string,
activeClassName?: string,
rejectClassName?: string,
ui: UiStore,
documents: DocumentsStore,
disabled: boolean,
@@ -28,18 +26,6 @@ type Props = {
staticContext: Object,
};
export const GlobalStyles = createGlobalStyle`
.activeDropZone {
border-radius: 4px;
background: ${(props) => props.theme.slateDark};
svg { fill: ${(props) => props.theme.white}; }
}
.activeDropZone a {
color: ${(props) => props.theme.white} !important;
}
`;
@observer
class DropToImport extends React.Component<Props> {
@observable isImporting: boolean = false;
@@ -74,7 +60,9 @@ class DropToImport extends React.Component<Props> {
}
}
} catch (err) {
this.props.ui.showToast(`Could not import file. ${err.message}`);
this.props.ui.showToast(`Could not import file. ${err.message}`, {
type: "error",
});
} finally {
this.isImporting = false;
importingLock = false;
@@ -82,18 +70,7 @@ class DropToImport extends React.Component<Props> {
};
render() {
const {
documentId,
collectionId,
documents,
disabled,
location,
match,
history,
staticContext,
ui,
...rest
} = this.props;
const { documents } = this.props;
if (this.props.disabled) return this.props.children;
@@ -102,16 +79,45 @@ class DropToImport extends React.Component<Props> {
accept={documents.importFileTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
style={EMPTY_OBJECT}
disableClick
disablePreview
noClick
multiple
{...rest}
>
{this.isImporting && <LoadingIndicator />}
{this.props.children}
{({
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
tabIndex="-1"
>
<input {...getInputProps()} />
{this.isImporting && <LoadingIndicator />}
{this.props.children}
</DropzoneContainer>
)}
</Dropzone>
);
}
}
const DropzoneContainer = styled("div")`
border-radius: 4px;
${({ isDragActive, theme }) =>
isDragActive &&
css`
background: ${theme.slateDark};
a {
color: ${theme.white} !important;
}
svg {
fill: ${theme.white};
}
`}
`;
export default inject("documents", "ui")(withRouter(DropToImport));
-289
View File
@@ -1,289 +0,0 @@
// @flow
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { MoreIcon } from "outline-icons";
import { rgba } from "polished";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { PortalWithState } from "react-portal";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import NudeButton from "components/NudeButton";
let previousClosePortal;
let counter = 0;
type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {|
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
children?: Children,
className?: string,
hover?: boolean,
style?: Object,
position?: "left" | "right" | "center",
t: TFunction,
|};
@observer
class DropdownMenu extends React.Component<Props> {
id: string = `menu${counter++}`;
closeTimeout: TimeoutID;
@observable top: ?number;
@observable bottom: ?number;
@observable right: ?number;
@observable left: ?number;
@observable position: "left" | "right" | "center";
@observable fixed: ?boolean;
@observable bodyRect: ClientRect;
@observable labelRect: ClientRect;
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
@observable menuRef: { current: null | HTMLElement } = React.createRef();
handleOpen = (
openPortal: (SyntheticEvent<>) => void,
closePortal: () => void
) => {
return (ev: SyntheticMouseEvent<HTMLElement>) => {
ev.preventDefault();
const currentTarget = ev.currentTarget;
invariant(document.body, "why you not here");
if (currentTarget instanceof HTMLDivElement) {
this.bodyRect = document.body.getBoundingClientRect();
this.labelRect = currentTarget.getBoundingClientRect();
this.top = this.labelRect.bottom - this.bodyRect.top;
this.bottom = undefined;
this.position = this.props.position || "left";
if (currentTarget.parentElement) {
const triggerParentStyle = getComputedStyle(
currentTarget.parentElement
);
if (triggerParentStyle.position === "static") {
this.fixed = true;
this.top = this.labelRect.bottom;
}
}
this.initPosition();
// attempt to keep only one flyout menu open at once
if (previousClosePortal && !this.props.hover) {
previousClosePortal();
}
previousClosePortal = closePortal;
openPortal(ev);
}
};
};
initPosition() {
if (this.position === "left") {
this.right =
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
} else if (this.position === "center") {
this.left = this.labelRect.left + this.labelRect.width / 2;
} else {
this.left = this.labelRect.left;
}
}
onOpen = () => {
if (typeof this.props.onOpen === "function") {
this.props.onOpen();
}
this.fitOnTheScreen();
};
fitOnTheScreen() {
if (!this.dropdownRef || !this.dropdownRef.current) return;
const el = this.dropdownRef.current;
const sticksOutPastBottomEdge =
el.clientHeight + this.top > window.innerHeight;
if (sticksOutPastBottomEdge) {
this.top = undefined;
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
} else {
this.bottom = undefined;
}
if (this.position === "left" || this.position === "right") {
const totalWidth =
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
el.scrollWidth;
const isVisible = totalWidth < window.innerWidth;
if (!isVisible) {
if (this.position === "right") {
this.position = "left";
this.left = undefined;
} else if (this.position === "left") {
this.position = "right";
this.right = undefined;
}
}
}
this.initPosition();
this.forceUpdate();
}
closeAfterTimeout = (closePortal: () => void) => () => {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
this.closeTimeout = setTimeout(closePortal, 500);
};
clearCloseTimeout = () => {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
};
render() {
const { className, hover, label, children, t } = this.props;
return (
<div className={className}>
<PortalWithState
onOpen={this.onOpen}
onClose={this.props.onClose}
closeOnOutsideClick
closeOnEsc
>
{({ closePortal, openPortal, isOpen, portal }) => (
<>
<Label
onMouseMove={hover ? this.clearCloseTimeout : undefined}
onMouseOut={
hover ? this.closeAfterTimeout(closePortal) : undefined
}
onMouseEnter={
hover ? this.handleOpen(openPortal, closePortal) : undefined
}
onClick={
hover ? undefined : this.handleOpen(openPortal, closePortal)
}
>
{label || (
<NudeButton
id={`${this.id}button`}
aria-label={t("More options")}
aria-haspopup="true"
aria-expanded={isOpen ? "true" : "false"}
aria-controls={this.id}
>
<MoreIcon />
</NudeButton>
)}
</Label>
{portal(
<Position
ref={this.dropdownRef}
position={this.position}
fixed={this.fixed}
top={this.top}
bottom={this.bottom}
left={this.left}
right={this.right}
>
<Menu
ref={this.menuRef}
onMouseMove={hover ? this.clearCloseTimeout : undefined}
onMouseOut={
hover ? this.closeAfterTimeout(closePortal) : undefined
}
onClick={
typeof children === "function"
? undefined
: (ev) => {
ev.stopPropagation();
closePortal();
}
}
style={this.props.style}
id={this.id}
aria-labelledby={`${this.id}button`}
role="menu"
>
{typeof children === "function"
? children({ closePortal })
: children}
</Menu>
</Position>
)}
</>
)}
</PortalWithState>
</div>
);
}
}
const Label = styled(Flex).attrs({
justify: "center",
align: "center",
})`
z-index: ${(props) => props.theme.depths.menu};
cursor: pointer;
`;
const Position = styled.div`
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
display: flex;
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
max-height: 75%;
z-index: ${(props) => props.theme.depths.menu};
transform: ${(props) =>
props.position === "center" ? "translateX(-50%)" : "initial"};
pointer-events: none;
`;
const Menu = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
backdrop-filter: blur(10px);
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
box-shadow: ${(props) => props.theme.menuShadow};
pointer-events: all;
hr {
margin: 0.5em 12px;
}
@media print {
display: none;
}
`;
export const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default withTranslation()<DropdownMenu>(DropdownMenu);
-3
View File
@@ -1,3 +0,0 @@
// @flow
export { default as DropdownMenu, Header } from "./DropdownMenu";
export { default as DropdownMenuItem } from "./DropdownMenuItem";
+15 -15
View File
@@ -9,6 +9,7 @@ import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import isInternalUrl from "utils/isInternalUrl";
import { isMetaKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
@@ -49,7 +50,7 @@ function Editor(props: PropsWithRef) {
return;
}
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
if (isInternalUrl(href) && !isMetaKey(event) && !event.shiftKey) {
// relative
let navigateTo = href;
@@ -102,7 +103,7 @@ function Editor(props: PropsWithRef) {
deleteTable: t("Delete table"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
findOrCreateDoc: t("Find or create a doc"),
findOrCreateDoc: `${t("Find or create a doc")}`,
h1: t("Big heading"),
h2: t("Medium heading"),
h3: t("Small heading"),
@@ -115,18 +116,18 @@ function Editor(props: PropsWithRef) {
link: t("Link"),
linkCopied: t("Link copied to clipboard"),
mark: t("Highlight"),
newLineEmpty: t("Type '/' to insert"),
newLineWithSlash: t("Keep typing to filter"),
newLineEmpty: `${t("Type '/' to insert")}`,
newLineWithSlash: `${t("Keep typing to filter")}`,
noResults: t("No results"),
openLink: t("Open link"),
orderedList: t("Ordered list"),
pasteLink: t("Paste a link"),
pasteLink: `${t("Paste a link")}`,
pasteLinkWithTitle: (service: string) =>
t("Paste a {{service}} link…", { service }),
placeholder: t("Placeholder"),
quote: t("Quote"),
removeLink: t("Remove link"),
searchOrPasteLink: t("Search or paste a link"),
searchOrPasteLink: `${t("Search or paste a link")}`,
strikethrough: t("Strikethrough"),
strong: t("Bold"),
subheading: t("Subheading"),
@@ -171,17 +172,16 @@ const StyledEditor = styled(RichMarkdownEditor)`
font-weight: 500;
}
.heading-name {
pointer-events: none;
.heading-anchor {
box-sizing: border-box;
}
/* pseudo element allows us to add spacing for fixed header */
/* ref: https://stackoverflow.com/a/28824157 */
.heading-name::before {
content: "";
display: ${(props) => (props.readOnly ? "block" : "none")};
height: 72px;
margin: -72px 0 0;
.heading-name {
pointer-events: none;
display: block;
position: relative;
top: -60px;
visibility: hidden;
}
.heading-name:first-child {
+7 -2
View File
@@ -4,13 +4,18 @@ import * as React from "react";
type Props = {
children: React.Node,
className?: string,
};
export default function EventBoundary({ children }: Props) {
export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
}, []);
return <span onClick={handleClick}>{children}</span>;
return (
<span onClick={handleClick} className={className}>
{children}
</span>
);
}
+3
View File
@@ -7,8 +7,11 @@ const Heading = styled.h1`
${(props) => (props.centered ? "text-align: center;" : "")}
svg {
margin-top: 4px;
margin-left: -6px;
margin-right: 2px;
align-self: flex-start;
flex-shrink: 0;
}
`;
+64 -93
View File
@@ -1,6 +1,4 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import {
CollectionIcon,
CoinsIcon,
@@ -22,14 +20,17 @@ import {
VehicleIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
import { DropdownMenu } from "components/DropdownMenu";
import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import { LabelText } from "components/Input";
import NudeButton from "components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() =>
import("react-color/lib/components/twitter/Twitter")
);
@@ -122,107 +123,77 @@ const colors = [
"#2F362F",
];
type Props = {
type Props = {|
onOpen?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
t: TFunction,
};
|};
function preventEventBubble(event) {
event.stopPropagation();
function IconPicker({ onOpen, icon, color, onChange }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
const Component = icons[icon || "collection"].component;
return (
<Wrapper>
<Label>
<LabelText>{t("Icon")}</LabelText>
</Label>
<MenuButton {...menu}>
{(props) => (
<Button {...props}>
<Component role="button" color={color} size={30} />
</Button>
)}
</MenuButton>
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
<Icons>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<MenuItem
key={name}
onClick={() => onChange(color, name)}
{...menu}
>
{(props) => (
<IconButton style={style} {...props}>
<Component color={color} size={30} />
</IconButton>
)}
</MenuItem>
);
})}
</Icons>
<Flex>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colors}
triangle="hide"
/>
</React.Suspense>
</Flex>
</ContextMenu>
</Wrapper>
);
}
@observer
class IconPicker extends React.Component<Props> {
@observable isOpen: boolean = false;
node: ?HTMLElement;
componentDidMount() {
window.addEventListener("click", this.handleClickOutside);
}
componentWillUnmount() {
window.removeEventListener("click", this.handleClickOutside);
}
handleClose = () => {
this.isOpen = false;
};
handleOpen = () => {
this.isOpen = true;
if (this.props.onOpen) {
this.props.onOpen();
}
};
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (ev.target && this.node && this.node.contains(ev.target)) {
return;
}
this.handleClose();
};
render() {
const { t } = this.props;
const Component = icons[this.props.icon || "collection"].component;
return (
<Wrapper ref={(ref) => (this.node = ref)}>
<label>
<LabelText>{t("Icon")}</LabelText>
</label>
<DropdownMenu
onOpen={this.handleOpen}
label={
<LabelButton>
<Component role="button" color={this.props.color} size={30} />
</LabelButton>
}
>
<Icons onClick={preventEventBubble}>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<IconButton
key={name}
onClick={() => this.props.onChange(this.props.color, name)}
style={{ width: 30, height: 30 }}
>
<Component color={this.props.color} size={30} />
</IconButton>
);
})}
</Icons>
<Flex onClick={preventEventBubble}>
<React.Suspense fallback={<Loading>{t("Loading…")}</Loading>}>
<ColorPicker
color={this.props.color}
onChange={(color) =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
</React.Suspense>
</Flex>
</DropdownMenu>
</Wrapper>
);
}
}
const Label = styled.label`
display: block;
`;
const Icons = styled.div`
padding: 15px 9px 9px 15px;
width: 276px;
`;
const LabelButton = styled(NudeButton)`
const Button = styled(NudeButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
width: 32px;
height: 32px;
@@ -249,4 +220,4 @@ const Wrapper = styled("div")`
position: relative;
`;
export default withTranslation()<IconPicker>(IconPicker);
export default IconPicker;
+3 -2
View File
@@ -9,6 +9,7 @@ import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Input from "./Input";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import { searchUrl } from "utils/routeHelpers";
type Props = {
@@ -25,7 +26,7 @@ class InputSearch extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
@keydown("meta+f")
@keydown(`${meta}+f`)
focus(ev: SyntheticEvent<>) {
ev.preventDefault();
@@ -54,7 +55,7 @@ class InputSearch extends React.Component<Props> {
render() {
const { t } = this.props;
const { theme, placeholder = t("Search") } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
return (
<InputMaxWidth
+2 -1
View File
@@ -9,7 +9,8 @@ import { Outline, LabelText } from "./Input";
const Select = styled.select`
border: 0;
flex: 1;
padding: 8px 12px;
padding: 8px 0;
margin: 0 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
+16 -5
View File
@@ -15,7 +15,6 @@ import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Analytics from "components/Analytics";
import DocumentHistory from "components/DocumentHistory";
import { GlobalStyles } from "components/DropToImport";
import Flex from "components/Flex";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
@@ -23,6 +22,7 @@ import Modal from "components/Modal";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import {
homeUrl,
searchUrl,
@@ -66,6 +66,11 @@ class Layout extends React.Component<Props> {
window.document.body.style.background = props.theme.background;
}
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
}
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
if (this.props.ui.editMode) return;
@@ -76,7 +81,7 @@ class Layout extends React.Component<Props> {
this.keyboardShortcutsOpen = false;
};
@keydown(["t", "/", "meta+k"])
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
if (this.props.ui.editMode) return;
ev.preventDefault();
@@ -120,7 +125,11 @@ class Layout extends React.Component<Props> {
</Switch>
)}
<Content auto justify="center" editMode={ui.editMode}>
<Content
auto
justify="center"
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
>
{this.props.children}
</Content>
@@ -138,7 +147,6 @@ class Layout extends React.Component<Props> {
>
<KeyboardShortcuts />
</Modal>
<GlobalStyles />
</Container>
);
}
@@ -161,7 +169,10 @@ const Content = styled(Flex)`
}
${breakpoint("tablet")`
margin-left: ${(props) => (props.editMode ? 0 : props.theme.sidebarWidth)};
margin-left: ${(props) =>
props.sidebarCollapsed
? props.theme.sidebarCollapsedWidth
: props.theme.sidebarWidth};
`};
`;
+87
View File
@@ -0,0 +1,87 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import format from "date-fns/format";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
const locales = {
en: require(`date-fns/locale/en`),
de: require(`date-fns/locale/de`),
es: require(`date-fns/locale/es`),
fr: require(`date-fns/locale/fr`),
ko: require(`date-fns/locale/ko`),
pt: require(`date-fns/locale/pt`),
};
let callbacks = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
type Props = {
dateTime: string,
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
};
function LocaleTime({
addSuffix,
children,
dateTime,
shorten,
tooltipDelay,
}: Props) {
const userLocale = useUserLocale();
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
const callback = React.useRef();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current();
}
};
}, []);
let content = distanceInWordsToNow(dateTime, {
addSuffix,
locale: userLocale ? locales[userLocale] : undefined,
});
if (shorten) {
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
return (
<Tooltip
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
}
export default LocaleTime;
+7 -5
View File
@@ -3,15 +3,17 @@ import * as React from "react";
import styled from "styled-components";
const Button = styled.button`
width: 24px;
height: 24px;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
background: none;
border-radius: 4px;
line-height: 0;
border: 0;
padding: 0;
cursor: pointer;
user-select: none;
`;
export default React.forwardRef<any, typeof Button>((props, ref) => (
<Button {...props} ref={ref} />
));
export default React.forwardRef<any, typeof Button>(
({ size = 24, ...props }, ref) => <Button size={size} {...props} ref={ref} />
);
+2 -2
View File
@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import Document from "models/Document";
import DocumentPreview from "components/DocumentPreview";
import DocumentListItem from "components/DocumentListItem";
import PaginatedList from "components/PaginatedList";
type Props = {
@@ -26,7 +26,7 @@ class PaginatedDocumentList extends React.Component<Props> {
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentPreview key={item.id} document={item} {...rest} />
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
+5 -4
View File
@@ -75,16 +75,17 @@ class MainSidebar extends React.Component<Props> {
return (
<Sidebar>
<AccountMenu
label={
<AccountMenu>
{(props) => (
<HeaderBlock
{...props}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
/>
}
/>
)}
</AccountMenu>
<Flex auto column>
<Scrollable shadow>
<Section>
+31 -3
View File
@@ -8,6 +8,7 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
import CollapseToggle, { Button } from "./components/CollapseToggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
@@ -30,10 +31,14 @@ function Sidebar({ location, children }: Props) {
const content = (
<Container
editMode={ui.editMode}
mobileSidebarVisible={ui.mobileSidebarVisible}
collapsed={ui.editMode || ui.sidebarCollapsed}
column
>
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
<Toggle
onClick={ui.toggleMobileSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
@@ -63,7 +68,7 @@ const Container = styled(Flex)`
bottom: 0;
width: 100%;
background: ${(props) => props.theme.sidebarBackground};
transition: left 100ms ease-out,
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
${(props) => props.theme.backgroundTransition};
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
z-index: ${(props) => props.theme.depths.sidebar};
@@ -90,10 +95,33 @@ const Container = styled(Flex)`
}
${breakpoint("tablet")`
left: ${(props) => (props.editMode ? `-${props.theme.sidebarWidth}` : 0)};
left: ${(props) =>
props.collapsed
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
: 0};
width: ${(props) => props.theme.sidebarWidth};
margin: 0;
z-index: 3;
&:hover,
&:focus-within {
left: 0;
box-shadow: ${(props) =>
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
& ${Button} {
opacity: .75;
}
& ${Button}:hover {
opacity: 1;
}
}
&:not(:hover):not(:focus-within) > div {
opacity: ${(props) => (props.collapsed ? "0" : "1")};
transition: opacity 100ms ease-in-out;
}
`};
`;
@@ -0,0 +1,59 @@
// @flow
import { NextIcon, BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Tooltip from "components/Tooltip";
import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
onClick?: () => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
const { t } = useTranslation();
return (
<Tooltip
tooltip={collapsed ? t("Expand") : t("Collapse")}
shortcut={`${meta}+.`}
delay={500}
placement="bottom"
>
<Button {...rest} aria-hidden>
{collapsed ? (
<NextIcon color="currentColor" />
) : (
<BackIcon color="currentColor" />
)}
</Button>
</Tooltip>
);
}
export const Button = styled.button`
display: block;
position: absolute;
top: 28px;
right: 8px;
border: 0;
width: 24px;
height: 24px;
z-index: 1;
font-weight: 600;
color: ${(props) => props.theme.sidebarText};
background: ${(props) => props.theme.sidebarItemBackground};
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;
cursor: pointer;
padding: 0;
&:hover {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
}
`;
export default CollapseToggle;
@@ -1,16 +1,20 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import styled from "styled-components";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import DropToImport from "components/DropToImport";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import CollectionMenu from "menus/CollectionMenu";
import CollectionSortMenu from "menus/CollectionSortMenu";
type Props = {|
collection: Collection,
@@ -20,33 +24,61 @@ type Props = {|
prefetchDocument: (id: string) => Promise<void>,
|};
@observer
class CollectionLink extends React.Component<Props> {
@observable menuOpen = false;
function CollectionLink({
collection,
activeDocument,
prefetchDocument,
canUpdate,
ui,
}: Props) {
const [menuOpen, setMenuOpen] = React.useState(false);
handleTitleChange = async (name: string) => {
await this.props.collection.save({ name });
};
const handleTitleChange = React.useCallback(
async (name: string) => {
await collection.save({ name });
},
[collection]
);
render() {
const {
collection,
activeDocument,
prefetchDocument,
canUpdate,
ui,
} = this.props;
const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId;
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
const expanded = collection.id === ui.activeCollectionId;
// Drop to re-parent
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id);
},
canDrop: (item, monitor) => {
return policies.abilities(collection.id).update;
},
collect: (monitor) => ({
isOver: !!monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
});
return (
<>
<DropToImport
key={collection.id}
collectionId={collection.id}
activeClassName="activeDropZone"
>
<SidebarLink
// Drop to reorder
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (!collection) return;
documents.move(item.id, collection.id, undefined, 0);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
}),
});
return (
<>
<div ref={drop} style={{ position: "relative" }}>
<DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLinkWithPadding
key={collection.id}
to={collection.url}
icon={
@@ -54,41 +86,62 @@ class CollectionLink extends React.Component<Props> {
}
iconColor={collection.color}
expanded={expanded}
menuOpen={this.menuOpen}
showActions={menuOpen || expanded}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
title={collection.name}
onSubmit={this.handleTitleChange}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
}
exact={false}
menu={
<CollectionMenu
position="right"
collection={collection}
onOpen={() => (this.menuOpen = true)}
onClose={() => (this.menuOpen = false)}
/>
<>
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
)}
<CollectionMenu
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</>
}
></SidebarLink>
/>
</DropToImport>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
{expanded &&
collection.documents.map((node) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
/>
))}
</>
);
}
{expanded &&
collection.documents.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
index={index}
/>
))}
</>
);
}
export default CollectionLink;
const SidebarLinkWithPadding = styled(SidebarLink)`
padding-right: 60px;
`;
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
margin-right: 4px;
`;
export default observer(CollectionLink);
@@ -72,7 +72,7 @@ class Collections extends React.Component<Props> {
to="/collections"
onClick={this.props.onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={t("New collection")}
label={`${t("New collection")}`}
exact
/>
</>
+167 -47
View File
@@ -2,12 +2,14 @@
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
import DropToImport from "components/DropToImport";
import Fade from "components/Fade";
import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
@@ -22,18 +24,22 @@ type Props = {|
activeDocumentRef?: (?HTMLElement) => void,
prefetchDocument: (documentId: string) => Promise<void>,
depth: number,
index: number,
parentId?: string,
|};
function DocumentLink({
node,
canUpdate,
collection,
activeDocument,
activeDocumentRef,
prefetchDocument,
depth,
canUpdate,
index,
parentId,
}: Props) {
const { documents } = useStores();
const { documents, policies } = useStores();
const { t } = useTranslation();
const isActiveDocument = activeDocument && activeDocument.id === node.id;
@@ -48,13 +54,19 @@ function DocumentLink({
}
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
const pathToNode = React.useMemo(
() =>
collection && collection.pathToDocument(node.id).map((entry) => entry.id),
[collection, node]
);
const showChildren = React.useMemo(() => {
return !!(
hasChildDocuments &&
activeDocument &&
collection &&
(collection
.pathToDocument(activeDocument)
.pathToDocument(activeDocument.id)
.map((entry) => entry.id)
.includes(node.id) ||
isActiveDocument)
@@ -69,6 +81,14 @@ function DocumentLink({
}
}, [showChildren]);
// when the last child document is removed,
// also close the local folder state to closed
React.useEffect(() => {
if (expanded && !hasChildDocuments) {
setExpanded(false);
}
}, [expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
@@ -100,53 +120,146 @@ function DocumentLink({
);
const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
// Draggable
const [{ isDragging }, drag] = useDrag({
item: { type: "document", ...node, depth, active: isActiveDocument },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
canDrag: (monitor) => {
return policies.abilities(node.id).move;
},
});
const hoverExpanding = React.useRef(null);
// We set a timeout when the user first starts hovering over the document link,
// to trigger expansion of children. Clear this timeout when they stop hovering.
const resetHoverExpanding = React.useCallback(() => {
if (hoverExpanding.current) {
clearTimeout(hoverExpanding.current);
hoverExpanding.current = null;
}
}, []);
// Drop to re-parent
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id, node.id);
},
canDrop: (item, monitor) =>
pathToNode && !pathToNode.includes(monitor.getItem().id),
hover: (item, monitor) => {
// Enables expansion of document children when hovering over the document
// for more than half a second.
if (
hasChildDocuments &&
monitor.canDrop() &&
monitor.isOver({ shallow: true })
) {
if (!hoverExpanding.current) {
hoverExpanding.current = setTimeout(() => {
hoverExpanding.current = null;
if (monitor.isOver({ shallow: true })) {
setExpanded(true);
}
}, 500);
}
}
},
collect: (monitor) => ({
isOverReparent: !!monitor.isOver({ shallow: true }),
canDropToReparent: monitor.canDrop(),
}),
});
// Drop to reorder
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (!collection) return;
if (item.id === node.id) return;
if (expanded) {
documents.move(item.id, collection.id, node.id, 0);
return;
}
documents.move(item.id, collection.id, parentId, index + 1);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
}),
});
return (
<React.Fragment key={node.id}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
<>
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
<Draggable
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
</>
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</>
}
depth={depth}
exact={false}
menuOpen={menuOpen}
menu={
document ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
></SidebarLink>
</DropToImport>
{expanded && (
</DropToImport>
</div>
</Draggable>
{manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
{expanded && !isDragging && (
<>
{node.children.map((childNode) => (
{node.children.map((childNode, index) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
@@ -155,14 +268,21 @@ function DocumentLink({
prefetchDocument={prefetchDocument}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</>
)}
</React.Fragment>
</>
);
}
const Draggable = styled("div")`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
`;
const Disclosure = styled(CollapsedIcon)`
position: absolute;
left: -24px;
@@ -0,0 +1,42 @@
// @flow
import * as React from "react";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
function DropCursor({
isActiveDrop,
innerRef,
theme,
}: {
isActiveDrop: boolean,
innerRef: React.Ref<any>,
theme: Theme,
}) {
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
}
// transparent hover zone with a thin visible band vertically centered
const Cursor = styled("div")`
opacity: ${(props) => (props.isOver ? 1 : 0)};
transition: opacity 150ms;
position: absolute;
z-index: 1;
width: 100%;
height: 14px;
bottom: -7px;
background: transparent;
::after {
background: ${(props) => props.theme.slateDark};
position: absolute;
top: 6px;
content: "";
height: 2px;
border-radius: 2px;
width: 100%;
}
`;
export default withTheme(DropCursor);
@@ -52,7 +52,9 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
setOriginalValue(value);
} catch (error) {
setValue(originalValue);
ui.showToast(error.message);
ui.showToast(error.message, {
type: "error",
});
throw error;
}
}
@@ -12,26 +12,22 @@ type Props = {
logoUrl: string,
};
function HeaderBlock({
showDisclosure,
teamName,
subheading,
logoUrl,
...rest
}: Props) {
return (
<Header justify="flex-start" align="center" {...rest}>
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
);
}
const HeaderBlock = React.forwardRef<Props, any>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => {
return (
<Header justify="flex-start" align="center" ref={ref} {...rest}>
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
);
}
);
const StyledExpandedIcon = styled(ExpandedIcon)`
position: absolute;
@@ -61,7 +57,7 @@ const Header = styled.button`
display: flex;
align-items: center;
flex-shrink: 0;
padding: 16px 24px;
padding: 20px 24px;
position: relative;
background: none;
line-height: inherit;
@@ -1,7 +1,13 @@
// @flow
import * as React from "react";
import { withRouter, NavLink } from "react-router-dom";
import {
withRouter,
NavLink,
type RouterHistory,
type Match,
} from "react-router-dom";
import styled, { withTheme } from "styled-components";
import EventBoundary from "components/EventBoundary";
import { type Theme } from "types";
type Props = {
@@ -10,13 +16,17 @@ type Props = {
innerRef?: (?HTMLElement) => void,
onClick?: (SyntheticEvent<>) => void,
onMouseEnter?: (SyntheticEvent<>) => void,
className?: string,
children?: React.Node,
icon?: React.Node,
label?: React.Node,
menu?: React.Node,
menuOpen?: boolean,
showActions?: boolean,
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
history: RouterHistory,
match: Match,
theme: Theme,
exact?: boolean,
depth?: number,
@@ -30,14 +40,17 @@ function SidebarLink({
to,
label,
active,
isActiveDrop,
menu,
menuOpen,
showActions,
theme,
exact,
href,
innerRef,
depth,
...rest
history,
match,
className,
}: Props) {
const style = React.useMemo(() => {
return {
@@ -46,15 +59,20 @@ function SidebarLink({
}, [depth]);
const activeStyle = {
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
fontWeight: 600,
...style,
};
const activeFontWeightOnly = {
fontWeight: 600,
};
return (
<StyledNavLink
activeStyle={activeStyle}
$isActiveDrop={isActiveDrop}
activeStyle={isActiveDrop ? activeFontWeightOnly : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={onMouseEnter}
@@ -63,10 +81,11 @@ function SidebarLink({
as={to ? undefined : href ? "a" : "div"}
href={href}
ref={innerRef}
className={className}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</StyledNavLink>
);
}
@@ -76,22 +95,26 @@ const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
`;
const Action = styled.span`
display: ${(props) => (props.menuOpen ? "inline" : "none")};
const Actions = styled(EventBoundary)`
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
position: absolute;
top: 4px;
right: 4px;
color: ${(props) => props.theme.textTertiary};
transition: opacity 50ms;
svg {
opacity: 0.75;
color: ${(props) => props.theme.textSecondary};
fill: currentColor;
opacity: 0.5;
}
&:hover {
svg {
opacity: 1;
opacity: 0.75;
}
}
`;
@@ -99,16 +122,26 @@ const Action = styled.span`
const StyledNavLink = styled(NavLink)`
display: flex;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 16px;
border-radius: 4px;
color: ${(props) => props.theme.sidebarText};
transition: background 50ms, color 50ms;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 15px;
cursor: pointer;
overflow: hidden;
svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
transition: fill 50ms
}
&:hover {
color: ${(props) => props.theme.text};
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
&:focus {
@@ -116,9 +149,14 @@ const StyledNavLink = styled(NavLink)`
background: ${(props) => props.theme.black05};
}
&:hover {
> ${Action} {
display: inline;
&:hover,
&:active {
> ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
}
`;
+3 -1
View File
@@ -110,7 +110,9 @@ class SocketProvider extends React.Component<Props> {
this.socket.on("unauthorized", (err) => {
this.socket.authenticated = false;
ui.showToast(err.message);
ui.showToast(err.message, {
type: "error",
});
throw err;
});
+59
View File
@@ -0,0 +1,59 @@
// @flow
import { StarredIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Document from "models/Document";
import NudeButton from "./NudeButton";
type Props = {|
document: Document,
size?: number,
|};
function Star({ size, document, ...rest }: Props) {
const handleClick = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
if (document.isStarred) {
document.unstar();
} else {
document.star();
}
},
[document]
);
if (!document) {
return null;
}
return (
<Button onClick={handleClick} size={size} {...rest}>
<AnimatedStar
solid={document.isStarred}
size={size}
color="currentColor"
/>
</Button>
);
}
const Button = styled(NudeButton)`
color: ${(props) => props.theme.text};
`;
export const AnimatedStar = styled(StarredIcon)`
flex-shrink: 0;
transition: all 100ms ease-in-out;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
`;
export default Star;
+19 -52
View File
@@ -1,24 +1,8 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import format from "date-fns/format";
import * as React from "react";
import Tooltip from "components/Tooltip";
let callbacks = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
const LocaleTime = React.lazy(() => import("components/LocaleTime"));
type Props = {
dateTime: string,
@@ -28,44 +12,27 @@ type Props = {
shorten?: boolean,
};
class Time extends React.Component<Props> {
removeEachMinuteCallback: () => void;
function Time(props: Props) {
let content = distanceInWordsToNow(props.dateTime, {
addSuffix: props.addSuffix,
});
componentDidMount() {
this.removeEachMinuteCallback = eachMinute(() => {
this.forceUpdate();
});
if (props.shorten) {
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
componentWillUnmount() {
this.removeEachMinuteCallback();
}
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 || content}
</time>
</Tooltip>
);
}
return (
<React.Suspense
fallback={
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime {...props} />
</React.Suspense>
);
}
export default Time;
+16 -22
View File
@@ -1,30 +1,24 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import UiStore from "../../stores/UiStore";
import Toast from "./components/Toast";
import useStores from "hooks/useStores";
type Props = {
ui: UiStore,
};
@observer
class Toasts extends React.Component<Props> {
render() {
const { ui } = this.props;
function Toasts() {
const { ui } = useStores();
return (
<List>
{ui.orderedToasts.map((toast) => (
<Toast
key={toast.id}
toast={toast}
onRequestClose={() => ui.removeToast(toast.id)}
/>
))}
</List>
);
}
return (
<List>
{ui.orderedToasts.map((toast) => (
<Toast
key={toast.id}
toast={toast}
onRequestClose={() => ui.removeToast(toast.id)}
/>
))}
</List>
);
}
const List = styled.ol`
@@ -37,4 +31,4 @@ const List = styled.ol`
z-index: ${(props) => props.theme.depths.toasts};
`;
export default inject("ui")(Toasts);
export default observer(Toasts);
+55 -42
View File
@@ -1,58 +1,61 @@
// @flow
import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import styled, { css } from "styled-components";
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
import type { Toast as TToast } from "types";
type Props = {
onRequestClose: () => void,
closeAfterMs: number,
closeAfterMs?: number,
toast: TToast,
};
class Toast extends React.Component<Props> {
timeout: TimeoutID;
function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
const timeout = React.useRef();
const [pulse, setPulse] = React.useState(false);
const { action, type = "info", reoccurring } = toast;
static defaultProps = {
closeAfterMs: 3000,
};
React.useEffect(() => {
timeout.current = setTimeout(onRequestClose, toast.timeout || closeAfterMs);
componentDidMount() {
this.timeout = setTimeout(
this.props.onRequestClose,
this.props.toast.timeout || this.props.closeAfterMs
);
}
return () => clearTimeout(timeout.current);
}, [onRequestClose, toast, closeAfterMs]);
componentWillUnmount() {
clearTimeout(this.timeout);
}
React.useEffect(() => {
if (reoccurring) {
setPulse(reoccurring);
render() {
const { toast, onRequestClose } = this.props;
const { action } = toast;
const message =
typeof toast.message === "string"
? toast.message
: toast.message.toString();
// must match animation time in css below vvv
setTimeout(() => setPulse(false), 250);
}
}, [reoccurring]);
return (
<li>
<Container
onClick={action ? undefined : onRequestClose}
type={toast.type || "success"}
>
<Message>{message}</Message>
{action && (
<Action type={toast.type || "success"} onClick={action.onClick}>
{action.text}
</Action>
)}
</Container>
</li>
);
}
const message =
typeof toast.message === "string"
? toast.message
: toast.message.toString();
return (
<ListItem $pulse={pulse}>
<Container
onClick={action ? undefined : onRequestClose}
type={toast.type || "success"}
>
{type === "info" && <InfoIcon color="currentColor" />}
{type === "success" && <CheckboxIcon checked color="currentColor" />}
{type === "warning" ||
(type === "error" && <WarningIcon color="currentColor" />)}
<Message>{message}</Message>
{action && (
<Action type={toast.type || "success"} onClick={action.onClick}>
{action.text}
</Action>
)}
</Container>
</ListItem>
);
}
const Action = styled.span`
@@ -71,11 +74,20 @@ const Action = styled.span`
}
`;
const ListItem = styled.li`
${(props) =>
props.$pulse &&
css`
animation: ${pulse} 250ms;
`}
`;
const Container = styled.div`
display: inline-block;
display: inline-flex;
align-items: center;
animation: ${fadeAndScaleIn} 100ms ease;
margin: 8px 0;
padding: 0 12px;
color: ${(props) => props.theme.toastText};
background: ${(props) => props.theme.toastBackground};
font-size: 15px;
@@ -89,7 +101,8 @@ const Container = styled.div`
const Message = styled.div`
display: inline-block;
padding: 10px 12px;
font-weight: 500;
padding: 10px 4px;
`;
export default Toast;
+37
View File
@@ -0,0 +1,37 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://drive.google.com/file/d/(.*)/(preview|view).?usp=sharing$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDrive extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
src={this.props.attrs.href.replace("/view", "/preview")}
icon={
<img
src="/images/google-drive.png"
alt="Google Drive Icon"
width={16}
height={16}
/>
}
title="Google Drive Embed"
canonicalUrl={this.props.attrs.href}
border
/>
);
}
}
+39
View File
@@ -0,0 +1,39 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDrive from "./GoogleDrive";
describe("GoogleDrive", () => {
const match = GoogleDrive.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
match
)
).toBeTruthy();
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view".match(
match
)
).toBe(null);
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview".match(
match
)
).toBe(null);
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=restricted".match(
match
)
).toBe(null);
expect("https://drive.google.com/file".match(match)).toBe(null);
expect("https://drive.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});
+6 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = /^https:\/\/(?:realtimeboard|miro).com\/app\/board\/(.*)$/;
const URL_REGEX = /^https:\/\/(realtimeboard|miro).com\/app\/board\/(.*)$/;
type Props = {|
attrs: {|
@@ -16,13 +16,15 @@ export default class RealtimeBoard extends React.Component<Props> {
render() {
const { matches } = this.props.attrs;
const boardId = matches[1];
const domain = matches[1];
const boardId = matches[2];
const titleName = domain === "realtimeboard" ? "RealtimeBoard" : "Miro";
return (
<Frame
{...this.props}
src={`https://realtimeboard.com/app/embed/${boardId}`}
title={`RealtimeBoard (${boardId})`}
src={`https://${domain}.com/app/embed/${boardId}`}
title={`${titleName} (${boardId})`}
/>
);
}
+6
View File
@@ -13,6 +13,12 @@ describe("Miro", () => {
expect("https://miro.com/app/board/o9J_k0fwiss=".match(match)).toBeTruthy();
});
test("to extract the domain as part of the match for later use", () => {
expect(
"https://realtimeboard.com/app/board/o9J_k0fwiss=".match(match)[1]
).toBe("realtimeboard");
});
test("to not be enabled elsewhere", () => {
expect("https://miro.com".match(match)).toBe(null);
expect("https://realtimeboard.com".match(match)).toBe(null);
+8
View File
@@ -9,6 +9,7 @@ import Figma from "./Figma";
import Framer from "./Framer";
import Gist from "./Gist";
import GoogleDocs from "./GoogleDocs";
import GoogleDrive from "./GoogleDrive";
import GoogleSheets from "./GoogleSheets";
import GoogleSlides from "./GoogleSlides";
import InVision from "./InVision";
@@ -93,6 +94,13 @@ export default [
component: Gist,
matcher: matcher(Gist),
},
{
title: "Google Drive",
keywords: "drive",
icon: () => <Img src="/images/google-drive.png" />,
component: GoogleDrive,
matcher: matcher(GoogleDrive),
},
{
title: "Google Docs",
icon: () => <Img src="/images/google-docs.png" />,
+12
View File
@@ -0,0 +1,12 @@
// @flow
import useStores from "./useStores";
export default function useUserLocale() {
const { auth } = useStores();
if (!auth.user) {
return undefined;
}
return auth.user.language.split("_")[0];
}
+18 -16
View File
@@ -1,11 +1,11 @@
// @flow
import "mobx-react-lite/batchingForReactDom";
import "focus-visible";
import { Provider } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { render } from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import { initI18n } from "shared/i18n";
import stores from "stores";
import ErrorBoundary from "components/ErrorBoundary";
@@ -21,20 +21,22 @@ const element = document.getElementById("root");
if (element) {
render(
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<Router>
<>
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</Theme>
</Provider>
</ErrorBoundary>,
<Provider {...stores}>
<Theme>
<ErrorBoundary>
<DndProvider backend={HTML5Backend}>
<Router>
<>
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</DndProvider>
</ErrorBoundary>
</Theme>
</Provider>,
element
);
}
+106 -112
View File
@@ -1,134 +1,128 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import { SunIcon, MoonIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Flex from "components/Flex";
import Modal from "components/Modal";
import {
developers,
changelog,
githubIssuesUrl,
mailToUrl,
settings,
} from "../../shared/utils/routeHelpers";
} from "shared/utils/routeHelpers";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import ContextMenu from "components/ContextMenu";
import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
import Separator from "components/ContextMenu/Separator";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
type Props = {
label: React.Node,
ui: UiStore,
auth: AuthStore,
t: TFunction,
};
type Props = {|
children: (props: any) => React.Node,
|};
@observer
class AccountMenu extends React.Component<Props> {
@observable keyboardShortcutsOpen: boolean = false;
const AppearanceMenu = React.forwardRef((props, ref) => {
const { ui } = useStores();
const { t } = useTranslation();
const menu = useMenuState();
handleLogout = () => {
this.props.auth.logout();
};
handleOpenKeyboardShortcuts = () => {
this.keyboardShortcutsOpen = true;
};
handleCloseKeyboardShortcuts = () => {
this.keyboardShortcutsOpen = false;
};
render() {
const { ui, t } = this.props;
return (
<>
<Modal
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
return (
<>
<MenuButton ref={ref} {...menu} {...props}>
{(props) => (
<MenuAnchor {...props}>
<ChangeTheme justify="space-between">
{t("Appearance")}
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
</ChangeTheme>
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Appearance")}>
<MenuItem
{...menu}
onClick={() => ui.setTheme("system")}
selected={ui.theme === "system"}
>
<KeyboardShortcuts />
</Modal>
<DropdownMenu
style={{ marginRight: 10, marginTop: -10 }}
label={this.props.label}
{t("System")}
</MenuItem>
<MenuItem
{...menu}
onClick={() => ui.setTheme("light")}
selected={ui.theme === "light"}
>
<DropdownMenuItem as={Link} to={settings()}>
{t("Settings")}
</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
{t("Keyboard shortcuts")}
</DropdownMenuItem>
<DropdownMenuItem href={developers()} target="_blank">
{t("API documentation")}
</DropdownMenuItem>
<hr />
<DropdownMenuItem href={changelog()} target="_blank">
{t("Changelog")}
</DropdownMenuItem>
<DropdownMenuItem href={mailToUrl()} target="_blank">
{t("Send us feedback")}
</DropdownMenuItem>
<DropdownMenuItem href={githubIssuesUrl()} target="_blank">
{t("Report a bug")}
</DropdownMenuItem>
<hr />
<DropdownMenu
position="right"
style={{
left: 170,
position: "relative",
top: -40,
}}
label={
<DropdownMenuItem>
<ChangeTheme justify="space-between">
{t("Appearance")}
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
</ChangeTheme>
</DropdownMenuItem>
}
hover
>
<DropdownMenuItem
onClick={() => ui.setTheme("system")}
selected={ui.theme === "system"}
>
{t("System")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ui.setTheme("light")}
selected={ui.theme === "light"}
>
{t("Light")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => ui.setTheme("dark")}
selected={ui.theme === "dark"}
>
{t("Dark")}
</DropdownMenuItem>
</DropdownMenu>
<hr />
<DropdownMenuItem onClick={this.handleLogout}>
{t("Log out")}
</DropdownMenuItem>
</DropdownMenu>
</>
);
}
{t("Light")}
</MenuItem>
<MenuItem
{...menu}
onClick={() => ui.setTheme("dark")}
selected={ui.theme === "dark"}
>
{t("Dark")}
</MenuItem>
</ContextMenu>
</>
);
});
function AccountMenu(props: Props) {
const menu = useMenuState({
placement: "bottom-start",
modal: true,
});
const { auth } = useStores();
const { t } = useTranslation();
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
false
);
return (
<>
<Modal
isOpen={keyboardShortcutsOpen}
onRequestClose={() => setKeyboardShortcutsOpen(false)}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Modal>
<MenuButton {...menu}>{props.children}</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<MenuItem {...menu} as={Link} to={settings()}>
{t("Settings")}
</MenuItem>
<MenuItem {...menu} onClick={() => setKeyboardShortcutsOpen(true)}>
{t("Keyboard shortcuts")}
</MenuItem>
<MenuItem {...menu} href={developers()} target="_blank">
{t("API documentation")}
</MenuItem>
<Separator {...menu} />
<MenuItem {...menu} href={changelog()} target="_blank">
{t("Changelog")}
</MenuItem>
<MenuItem {...menu} href={mailToUrl()} target="_blank">
{t("Send us feedback")}
</MenuItem>
<MenuItem {...menu} href={githubIssuesUrl()} target="_blank">
{t("Report a bug")}
</MenuItem>
<Separator {...menu} />
<MenuItem {...menu} as={AppearanceMenu} />
<Separator {...menu} />
<MenuItem {...menu} onClick={auth.logout}>
{t("Log out")}
</MenuItem>
</ContextMenu>
</>
);
}
const ChangeTheme = styled(Flex)`
width: 100%;
`;
export default withTranslation()<AccountMenu>(
inject("ui", "auth")(AccountMenu)
);
export default observer(AccountMenu);
+34
View File
@@ -0,0 +1,34 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {
path: Array<any>,
};
export default function BreadcrumbMenu({ path }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom",
});
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}>
<Template
{...menu}
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</ContextMenu>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {|
onMembers: () => void,
onRemove: () => void,
|};
function CollectionGroupMemberMenu({ onMembers, onRemove }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Group member options")}>
<Template
{...menu}
items={[
{
title: t("Members"),
onClick: onMembers,
},
{
type: "separator",
},
{
title: t("Remove"),
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(CollectionGroupMemberMenu);
+193 -193
View File
@@ -1,221 +1,221 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import Collection from "models/Collection";
import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport";
import CollectionMembers from "scenes/CollectionMembers";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Modal from "components/Modal";
import VisuallyHidden from "components/VisuallyHidden";
import useStores from "hooks/useStores";
import getDataTransferFiles from "utils/getDataTransferFiles";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
position?: "left" | "right" | "center",
ui: UiStore,
policies: PoliciesStore,
documents: DocumentsStore,
type Props = {|
collection: Collection,
history: RouterHistory,
placement?: string,
modal?: boolean,
label?: (any) => React.Node,
onOpen?: () => void,
onClose?: () => void,
t: TFunction,
};
|};
@observer
class CollectionMenu extends React.Component<Props> {
file: ?HTMLInputElement;
@observable showCollectionMembers = false;
@observable showCollectionEdit = false;
@observable showCollectionDelete = false;
@observable showCollectionExport = false;
function CollectionMenu({
collection,
label,
modal = true,
placement,
onOpen,
onClose,
}: Props) {
const menu = useMenuState({ modal, placement });
const [renderModals, setRenderModals] = React.useState(false);
const { ui, documents, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
onNewDocument = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { collection } = this.props;
this.props.history.push(newDocumentUrl(collection.id));
};
const file = React.useRef<?HTMLInputElement>();
const [showCollectionMembers, setShowCollectionMembers] = React.useState(
false
);
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
const [showCollectionDelete, setShowCollectionDelete] = React.useState(false);
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
onImportDocument = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
// simulate a click on the file upload input element
if (this.file) this.file.click();
};
onFilePicked = async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
try {
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);
const handleOpen = React.useCallback(() => {
setRenderModals(true);
if (onOpen) {
onOpen();
}
};
}, [onOpen]);
handleEditCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionEdit = true;
};
const handleNewDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
history.push(newDocumentUrl(collection.id));
},
[history, collection.id]
);
handleEditCollectionClose = () => {
this.showCollectionEdit = false;
};
const handleImportDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
handleDeleteCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionDelete = true;
};
// simulate a click on the file upload input element
if (file.current) {
file.current.click();
}
},
[file]
);
handleDeleteCollectionClose = () => {
this.showCollectionDelete = false;
};
const handleFilePicked = React.useCallback(
async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
handleExportCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionExport = true;
};
try {
const file = files[0];
const document = await documents.import(
file,
null,
this.props.collection.id,
{ publish: true }
);
history.push(document.url);
} catch (err) {
ui.showToast(err.message, {
type: "error",
});
}
},
[history, ui, documents]
);
handleExportCollectionClose = () => {
this.showCollectionExport = false;
};
const can = policies.abilities(collection.id);
handleMembersModalOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionMembers = true;
};
handleMembersModalClose = () => {
this.showCollectionMembers = false;
};
render() {
const {
policies,
documents,
collection,
position,
onOpen,
onClose,
t,
} = this.props;
const can = policies.abilities(collection.id);
return (
<>
<VisuallyHidden>
<input
type="file"
ref={(ref) => (this.file = ref)}
onChange={this.onFilePicked}
onClick={(ev) => ev.stopPropagation()}
accept={documents.importFileTypes.join(", ")}
/>
</VisuallyHidden>
<Modal
title={t("Collection permissions")}
onRequestClose={this.handleMembersModalClose}
isOpen={this.showCollectionMembers}
>
<CollectionMembers
collection={collection}
onSubmit={this.handleMembersModalClose}
handleEditCollectionOpen={this.handleEditCollectionOpen}
onEdit={this.handleEditCollectionOpen}
/>
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
<DropdownMenuItems
items={[
{
title: t("New document"),
visible: !!(collection && can.update),
onClick: this.onNewDocument,
},
{
title: t("Import document"),
visible: !!(collection && can.update),
onClick: this.onImportDocument,
},
{
type: "separator",
},
{
title: t("Edit…"),
visible: !!(collection && can.update),
onClick: this.handleEditCollectionOpen,
},
{
title: t("Permissions…"),
visible: !!(collection && can.update),
onClick: this.handleMembersModalOpen,
},
{
title: t("Export…"),
visible: !!(collection && can.export),
onClick: this.handleExportCollectionOpen,
},
{
title: t("Delete…"),
visible: !!(collection && can.delete),
onClick: this.handleDeleteCollectionOpen,
},
]}
/>
</DropdownMenu>
<Modal
title={t("Edit collection")}
isOpen={this.showCollectionEdit}
onRequestClose={this.handleEditCollectionClose}
>
<CollectionEdit
onSubmit={this.handleEditCollectionClose}
collection={collection}
/>
</Modal>
<Modal
title={t("Delete collection")}
isOpen={this.showCollectionDelete}
onRequestClose={this.handleDeleteCollectionClose}
>
<CollectionDelete
onSubmit={this.handleDeleteCollectionClose}
collection={collection}
/>
</Modal>
<Modal
title={t("Export collection")}
isOpen={this.showCollectionExport}
onRequestClose={this.handleExportCollectionClose}
>
<CollectionExport
onSubmit={this.handleExportCollectionClose}
collection={collection}
/>
</Modal>
</>
);
}
return (
<>
<VisuallyHidden>
<input
type="file"
ref={file}
onChange={handleFilePicked}
onClick={(ev) => ev.stopPropagation()}
accept={documents.importFileTypes.join(", ")}
tabIndex="-1"
/>
</VisuallyHidden>
{label ? (
<MenuButton {...menu}>{label}</MenuButton>
) : (
<OverflowMenuButton {...menu} />
)}
<ContextMenu
{...menu}
onOpen={handleOpen}
onClose={onClose}
aria-label={t("Collection")}
>
<Template
{...menu}
items={[
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionMembers(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]}
/>
</ContextMenu>
{renderModals && (
<>
<Modal
title={t("Collection permissions")}
onRequestClose={() => setShowCollectionMembers(false)}
isOpen={showCollectionMembers}
>
<CollectionMembers
collection={collection}
onSubmit={() => setShowCollectionMembers(false)}
onEdit={() => setShowCollectionEdit(true)}
/>
</Modal>
<Modal
title={t("Edit collection")}
isOpen={showCollectionEdit}
onRequestClose={() => setShowCollectionEdit(false)}
>
<CollectionEdit
onSubmit={() => setShowCollectionEdit(false)}
collection={collection}
/>
</Modal>
<Modal
title={t("Delete collection")}
isOpen={showCollectionDelete}
onRequestClose={() => setShowCollectionDelete(false)}
>
<CollectionDelete
onSubmit={() => setShowCollectionDelete(false)}
collection={collection}
/>
</Modal>
<Modal
title={t("Export collection")}
isOpen={showCollectionExport}
onRequestClose={() => setShowCollectionExport(false)}
>
<CollectionExport
onSubmit={() => setShowCollectionExport(false)}
collection={collection}
/>
</Modal>
</>
)}
</>
);
}
export default withTranslation()<CollectionMenu>(
inject("ui", "documents", "policies")(withRouter(CollectionMenu))
);
export default observer(CollectionMenu);
+72
View File
@@ -0,0 +1,72 @@
// @flow
import { observer } from "mobx-react";
import { AlphabeticalSortIcon, ManualSortIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import Collection from "models/Collection";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import NudeButton from "components/NudeButton";
type Props = {|
collection: Collection,
onOpen?: () => void,
onClose?: () => void,
|};
function CollectionSortMenu({ collection, onOpen, onClose, ...rest }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
const handleChangeSort = React.useCallback(
(field: string) => {
menu.hide();
return collection.save({
sort: {
field,
direction: "asc",
},
});
},
[collection, menu]
);
const alphabeticalSort = collection.sort.field === "title";
return (
<>
<MenuButton {...menu}>
{(props) => (
<NudeButton {...props}>
{alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />}
</NudeButton>
)}
</MenuButton>
<ContextMenu
{...menu}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Sort in sidebar")}
>
<Template
{...menu}
items={[
{
title: t("Alphabetical sort"),
onClick: () => handleChangeSort("title"),
selected: alphabeticalSort,
},
{
title: t("Manual sort"),
onClick: () => handleChangeSort("index"),
selected: !alphabeticalSort,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(CollectionSortMenu);
+299 -328
View File
@@ -1,21 +1,21 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Redirect } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentShare from "scenes/DocumentShare";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import {
documentHistoryUrl,
documentMoveUrl,
@@ -24,348 +24,319 @@ import {
newDocumentUrl,
} from "utils/routeHelpers";
type Props = {
ui: UiStore,
auth: AuthStore,
position?: "left" | "right" | "center",
type Props = {|
document: Document,
collections: CollectionStore,
policies: PoliciesStore,
className: string,
isRevision?: boolean,
showPrint?: boolean,
modal?: boolean,
showToggleEmbeds?: boolean,
showPin?: boolean,
label?: React.Node,
label?: (any) => React.Node,
onOpen?: () => void,
onClose?: () => void,
t: TFunction,
};
|};
@observer
class DocumentMenu extends React.Component<Props> {
@observable redirectTo: ?string;
@observable showDeleteModal = false;
@observable showTemplateModal = false;
@observable showShareModal = false;
function DocumentMenu({
document,
isRevision,
className,
modal = true,
showToggleEmbeds,
showPrint,
showPin,
label,
onOpen,
onClose,
}: Props) {
const { policies, collections, auth, ui } = useStores();
const menu = useMenuState({ modal });
const history = useHistory();
const { t } = useTranslation();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
const [showShareModal, setShowShareModal] = React.useState(false);
componentDidUpdate() {
this.redirectTo = undefined;
}
handleNewChild = (ev: SyntheticEvent<>) => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
});
};
handleDelete = (ev: SyntheticEvent<>) => {
this.showDeleteModal = true;
};
handleDocumentHistory = () => {
if (this.props.isRevision) {
this.redirectTo = documentUrl(this.props.document);
} else {
this.redirectTo = documentHistoryUrl(this.props.document);
const handleOpen = React.useCallback(() => {
setRenderModals(true);
if (onOpen) {
onOpen();
}
};
}, [onOpen]);
handleMove = (ev: SyntheticEvent<>) => {
this.redirectTo = documentMoveUrl(this.props.document);
};
const handleDuplicate = React.useCallback(
async (ev: SyntheticEvent<>) => {
const duped = await document.duplicate();
handleEdit = (ev: SyntheticEvent<>) => {
this.redirectTo = editDocumentUrl(this.props.document);
};
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
ui.showToast(t("Document duplicated"), { type: "success" });
},
[ui, t, history, document]
);
handleDuplicate = async (ev: SyntheticEvent<>) => {
const duped = await this.props.document.duplicate();
const handleArchive = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.archive();
ui.showToast(t("Document archived"), { type: "success" });
},
[ui, t, document]
);
// when duplicating, go straight to the duplicated document content
this.redirectTo = duped.url;
const { t } = this.props;
this.props.ui.showToast(t("Document duplicated"));
};
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>, options?: { collectionId: string }) => {
await document.restore(options);
ui.showToast(t("Document restored"), { type: "success" });
},
[ui, t, document]
);
handleOpenTemplateModal = () => {
this.showTemplateModal = true;
};
const handleUnpublish = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.unpublish();
ui.showToast(t("Document unpublished"), { type: "success" });
},
[ui, t, document]
);
handleCloseTemplateModal = () => {
this.showTemplateModal = false;
};
const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
window.print();
}, []);
handleCloseDeleteModal = () => {
this.showDeleteModal = false;
};
const handleStar = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.stopPropagation();
document.star();
},
[document]
);
handleArchive = async (ev: SyntheticEvent<>) => {
await this.props.document.archive();
const { t } = this.props;
this.props.ui.showToast(t("Document archived"));
};
const handleUnstar = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.stopPropagation();
document.unstar();
},
[document]
);
handleRestore = async (
ev: SyntheticEvent<>,
options?: { collectionId: string }
) => {
await this.props.document.restore(options);
const { t } = this.props;
this.props.ui.showToast(t("Document restored"));
};
const handleShareLink = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.share();
setShowShareModal(true);
},
[document]
);
handleUnpublish = async (ev: SyntheticEvent<>) => {
await this.props.document.unpublish();
const { t } = this.props;
this.props.ui.showToast(t("Document unpublished"));
};
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);
handlePin = (ev: SyntheticEvent<>) => {
this.props.document.pin();
};
handleUnpin = (ev: SyntheticEvent<>) => {
this.props.document.unpin();
};
handleStar = (ev: SyntheticEvent<>) => {
ev.stopPropagation();
this.props.document.star();
};
handleUnstar = (ev: SyntheticEvent<>) => {
ev.stopPropagation();
this.props.document.unstar();
};
handleExport = (ev: SyntheticEvent<>) => {
this.props.document.download();
};
handleShareLink = async (ev: SyntheticEvent<>) => {
const { document } = this.props;
await document.share();
this.showShareModal = true;
};
handleCloseShareModal = () => {
this.showShareModal = false;
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const {
policies,
document,
position,
className,
showToggleEmbeds,
showPrint,
showPin,
auth,
collections,
label,
onOpen,
onClose,
t,
} = this.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 (
<>
<DropdownMenu
className={className}
position={position}
onOpen={onOpen}
onClose={onClose}
label={label}
>
<DropdownMenuItems
items={[
{
title: t("Restore"),
visible: !!can.unarchive,
onClick: this.handleRestore,
return (
<>
{label ? (
<MenuButton {...menu}>{label}</MenuButton>
) : (
<OverflowMenuButton className={className} {...menu} />
)}
<ContextMenu
{...menu}
aria-label={t("Document options")}
onOpen={handleOpen}
onClose={onClose}
>
<Template
{...menu}
items={[
{
title: t("Restore"),
visible: !!can.unarchive,
onClick: handleRestore,
},
{
title: t("Restore"),
visible: !!(collection && can.restore),
onClick: handleRestore,
},
{
title: t("Restore"),
visible: !collection && !!can.restore,
style: {
left: -170,
position: "relative",
top: -40,
},
{
title: t("Restore"),
visible: !!(collection && can.restore),
onClick: this.handleRestore,
},
{
title: t("Restore…"),
visible: !collection && !!can.restore,
style: {
left: -170,
position: "relative",
top: -40,
hover: true,
items: [
{
type: "heading",
title: t("Choose a collection"),
},
hover: true,
items: [
{
type: "heading",
title: t("Choose a collection"),
},
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return {
title: (
<>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
),
onClick: (ev) =>
this.handleRestore(ev, { collectionId: collection.id }),
disabled: !can.update,
};
}),
],
},
{
title: t("Unpin"),
onClick: this.handleUnpin,
visible: !!(showPin && document.pinned && can.unpin),
},
{
title: t("Pin to collection"),
onClick: this.handlePin,
visible: !!(showPin && !document.pinned && can.pin),
},
{
title: t("Unstar"),
onClick: this.handleUnstar,
visible: document.isStarred && !!can.unstar,
},
{
title: t("Star"),
onClick: this.handleStar,
visible: !document.isStarred && !!can.star,
},
{
title: t("Share link"),
onClick: this.handleShareLink,
visible: canShareDocuments,
},
{
title: t("Enable embeds"),
onClick: document.enableEmbeds,
visible: !!showToggleEmbeds && document.embedsDisabled,
},
{
title: t("Disable embeds"),
onClick: document.disableEmbeds,
visible: !!showToggleEmbeds && !document.embedsDisabled,
},
{
type: "separator",
},
{
title: t("New nested document"),
onClick: this.handleNewChild,
visible: !!can.createChildDocument,
},
{
title: t("Create template…"),
onClick: this.handleOpenTemplateModal,
visible: !!can.update && !document.isTemplate,
},
{
title: t("Edit"),
onClick: this.handleEdit,
visible: !!can.update,
},
{
title: t("Duplicate"),
onClick: this.handleDuplicate,
visible: !!can.update,
},
{
title: t("Unpublish"),
onClick: this.handleUnpublish,
visible: !!can.unpublish,
},
{
title: t("Archive"),
onClick: this.handleArchive,
visible: !!can.archive,
},
{
title: t("Delete…"),
onClick: this.handleDelete,
visible: !!can.delete,
},
{
title: t("Move…"),
onClick: this.handleMove,
visible: !!can.move,
},
{
type: "separator",
},
{
title: t("History"),
onClick: this.handleDocumentHistory,
visible: canViewHistory,
},
{
title: t("Download"),
onClick: this.handleExport,
visible: !!can.download,
},
{
title: t("Print"),
onClick: window.print,
visible: !!showPrint,
},
]}
/>
</DropdownMenu>
<Modal
title={t("Delete {{ documentName }}", {
documentName: this.props.document.noun,
})}
onRequestClose={this.handleCloseDeleteModal}
isOpen={this.showDeleteModal}
>
<DocumentDelete
document={this.props.document}
onSubmit={this.handleCloseDeleteModal}
/>
</Modal>
<Modal
title={t("Create template")}
onRequestClose={this.handleCloseTemplateModal}
isOpen={this.showTemplateModal}
>
<DocumentTemplatize
document={this.props.document}
onSubmit={this.handleCloseTemplateModal}
/>
</Modal>
<Modal
title={t("Share document")}
onRequestClose={this.handleCloseShareModal}
isOpen={this.showShareModal}
>
<DocumentShare
document={this.props.document}
onSubmit={this.handleCloseShareModal}
/>
</Modal>
</>
);
}
return {
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
onClick: (ev) =>
handleRestore(ev, { collectionId: collection.id }),
disabled: !can.update,
};
}),
],
},
{
title: t("Unpin"),
onClick: document.unpin,
visible: !!(showPin && document.pinned && can.unpin),
},
{
title: t("Pin to collection"),
onClick: document.pin,
visible: !!(showPin && !document.pinned && can.pin),
},
{
title: t("Unstar"),
onClick: handleUnstar,
visible: document.isStarred && !!can.unstar,
},
{
title: t("Star"),
onClick: handleStar,
visible: !document.isStarred && !!can.star,
},
{
title: `${t("Share link")}`,
onClick: handleShareLink,
visible: canShareDocuments,
},
{
title: t("Enable embeds"),
onClick: document.enableEmbeds,
visible: !!showToggleEmbeds && document.embedsDisabled,
},
{
title: t("Disable embeds"),
onClick: document.disableEmbeds,
visible: !!showToggleEmbeds && !document.embedsDisabled,
},
{
type: "separator",
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
}),
visible: !!can.createChildDocument,
},
{
title: `${t("Create template")}`,
onClick: () => setShowTemplateModal(true),
visible: !!can.update && !document.isTemplate,
},
{
title: t("Edit"),
to: editDocumentUrl(document),
visible: !!can.update,
},
{
title: t("Duplicate"),
onClick: handleDuplicate,
visible: !!can.update,
},
{
title: t("Unpublish"),
onClick: handleUnpublish,
visible: !!can.unpublish,
},
{
title: t("Archive"),
onClick: handleArchive,
visible: !!can.archive,
},
{
title: `${t("Delete")}`,
onClick: () => setShowDeleteModal(true),
visible: !!can.delete,
},
{
title: `${t("Move")}`,
to: documentMoveUrl(document),
visible: !!can.move,
},
{
type: "separator",
},
{
title: t("History"),
to: isRevision
? documentUrl(document)
: documentHistoryUrl(document),
visible: canViewHistory,
},
{
title: t("Download"),
onClick: document.download,
visible: !!can.download,
},
{
title: t("Print"),
onClick: handlePrint,
visible: !!showPrint,
},
]}
/>
</ContextMenu>
{renderModals && (
<>
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
<Modal
title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)}
isOpen={showTemplateModal}
>
<DocumentTemplatize
document={document}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
<Modal
title={t("Share document")}
onRequestClose={() => setShowShareModal(false)}
isOpen={showShareModal}
>
<DocumentShare
document={document}
onSubmit={() => setShowShareModal(false)}
/>
</Modal>
</>
)}
</>
);
}
export default withTranslation()<DocumentMenu>(
inject("ui", "auth", "collections", "policies")(DocumentMenu)
);
const CollectionName = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(DocumentMenu);
+36
View File
@@ -0,0 +1,36 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {|
onRemove: () => void,
|};
function GroupMemberMenu({ onRemove }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Group member options")}>
<Template
{...menu}
items={[
{
title: t("Remove"),
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(GroupMemberMenu);
+61 -95
View File
@@ -1,108 +1,74 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Group from "models/Group";
import GroupDelete from "scenes/GroupDelete";
import GroupEdit from "scenes/GroupEdit";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
type Props = {
ui: UiStore,
policies: PoliciesStore,
type Props = {|
group: Group,
history: RouterHistory,
onMembers: () => void,
onOpen?: () => void,
onClose?: () => void,
t: TFunction,
};
|};
@observer
class GroupMenu extends React.Component<Props> {
@observable editModalOpen: boolean = false;
@observable deleteModalOpen: boolean = false;
function GroupMenu({ group, onMembers }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const menu = useMenuState({ modal: true });
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = policies.abilities(group.id);
onEdit = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.editModalOpen = true;
};
onDelete = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.deleteModalOpen = true;
};
handleEditModalClose = () => {
this.editModalOpen = false;
};
handleDeleteModalClose = () => {
this.deleteModalOpen = false;
};
render() {
const { policies, group, onOpen, onClose, t } = this.props;
const can = policies.abilities(group.id);
return (
<>
<Modal
title={t("Edit group")}
onRequestClose={this.handleEditModalClose}
isOpen={this.editModalOpen}
>
<GroupEdit
group={this.props.group}
onSubmit={this.handleEditModalClose}
/>
</Modal>
<Modal
title={t("Delete group")}
onRequestClose={this.handleDeleteModalClose}
isOpen={this.deleteModalOpen}
>
<GroupDelete
group={this.props.group}
onSubmit={this.handleDeleteModalClose}
/>
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose}>
<DropdownMenuItems
items={[
{
title: t("Members…"),
onClick: this.props.onMembers,
visible: !!(group && can.read),
},
{
type: "separator",
},
{
title: t("Edit…"),
onClick: this.onEdit,
visible: !!(group && can.update),
},
{
title: t("Delete…"),
onClick: this.onDelete,
visible: !!(group && can.delete),
},
]}
/>
</DropdownMenu>
</>
);
}
return (
<>
<Modal
title={t("Edit group")}
onRequestClose={() => setEditModalOpen(false)}
isOpen={editModalOpen}
>
<GroupEdit group={group} onSubmit={() => setEditModalOpen(false)} />
</Modal>
<Modal
title={t("Delete group")}
onRequestClose={() => setDeleteModalOpen(false)}
isOpen={deleteModalOpen}
>
<GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
</Modal>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Group options")}>
<Template
{...menu}
items={[
{
title: `${t("Members")}`,
onClick: onMembers,
visible: !!(group && can.read),
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
onClick: () => setEditModalOpen(true),
visible: !!(group && can.update),
},
{
title: `${t("Delete")}`,
onClick: () => setDeleteModalOpen(true),
visible: !!(group && can.delete),
},
]}
/>
</ContextMenu>
</>
);
}
export default withTranslation()<GroupMenu>(
inject("policies")(withRouter(GroupMenu))
);
export default observer(GroupMenu);
+36
View File
@@ -0,0 +1,36 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {|
onRemove: () => void,
|};
function MemberMenu({ onRemove }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Member options")}>
<Template
{...menu}
items={[
{
title: t("Remove"),
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(MemberMenu);
+30 -51
View File
@@ -1,75 +1,54 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Redirect } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import { useTranslation, Trans } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import Document from "models/Document";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
label?: React.Node,
label?: (any) => React.Node,
document: Document,
collections: CollectionsStore,
t: TFunction,
};
@observer
class NewChildDocumentMenu extends React.Component<Props> {
@observable redirectTo: ?string;
function NewChildDocumentMenu({ document, label }: Props) {
const menu = useMenuState({ modal: true });
const { collections } = useStores();
const { t } = useTranslation();
const collection = collections.get(document.collectionId);
const collectionName = collection ? collection.name : t("collection");
componentDidUpdate() {
this.redirectTo = undefined;
}
handleNewDocument = () => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId);
};
handleNewChild = () => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
});
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { label, document, collections, t } = this.props;
const collection = collections.get(document.collectionId);
return (
<DropdownMenu label={label}>
<DropdownMenuItems
return (
<>
<MenuButton {...menu}>{label}</MenuButton>
<ContextMenu {...menu} aria-label={t("New child document")}>
<Template
{...menu}
items={[
{
title: (
<span>
{t("New document in")}{" "}
<strong>
{collection ? collection.name : t("collection")}
</strong>
<Trans>
New document in <strong>{{ collectionName }}</strong>
</Trans>
</span>
),
onClick: this.handleNewDocument,
to: newDocumentUrl(document.collectionId),
},
{
title: t("New nested document"),
onClick: this.handleNewChild,
to: newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
}),
},
]}
/>
</DropdownMenu>
);
}
</ContextMenu>
</>
);
}
export default withTranslation()<NewChildDocumentMenu>(
inject("collections")(NewChildDocumentMenu)
);
export default observer(NewChildDocumentMenu);
+52 -72
View File
@@ -1,92 +1,72 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Redirect } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu, Header } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import ContextMenu from "components/ContextMenu";
import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
label?: React.Node,
documents: DocumentsStore,
collections: CollectionsStore,
policies: PoliciesStore,
t: TFunction,
};
function NewDocumentMenu() {
const menu = useMenuState();
const { t } = useTranslation();
const { collections, policies } = useStores();
const singleCollection = collections.orderedData.length === 1;
@observer
class NewDocumentMenu extends React.Component<Props> {
@observable redirectTo: ?string;
componentDidUpdate() {
this.redirectTo = undefined;
if (singleCollection) {
return (
<Button
as={Link}
to={newDocumentUrl(collections.orderedData[0].id)}
icon={<PlusIcon />}
small
>
{t("New doc")}
</Button>
);
}
handleNewDocument = (
collectionId: string,
options?: {
parentDocumentId?: string,
template?: boolean,
templateId?: string,
}
) => {
this.redirectTo = newDocumentUrl(collectionId, options);
};
onOpen = () => {
const { collections } = this.props;
if (collections.orderedData.length === 1) {
this.handleNewDocument(collections.orderedData[0].id);
}
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { collections, documents, policies, label, t, ...rest } = this.props;
const singleCollection = collections.orderedData.length === 1;
return (
<DropdownMenu
label={
label || (
<Button icon={<PlusIcon />} small>
{t("New doc")}
{singleCollection ? "" : "…"}
</Button>
)
}
onOpen={this.onOpen}
{...rest}
>
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button icon={<PlusIcon />} {...props} small>
{`${t("New doc")}`}
</Button>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("New document")}>
<Header>{t("Choose a collection")}</Header>
<DropdownMenuItems
<Template
{...menu}
items={collections.orderedData.map((collection) => ({
onClick: () => this.handleNewDocument(collection.id),
to: newDocumentUrl(collection.id),
disabled: !policies.abilities(collection.id).update,
title: (
<>
<Flex align="center">
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
}))}
/>
</DropdownMenu>
);
}
</ContextMenu>
</>
);
}
export default withTranslation()<NewDocumentMenu>(
inject("collections", "documents", "policies")(NewDocumentMenu)
);
const CollectionName = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(NewDocumentMenu);
+41 -56
View File
@@ -1,74 +1,59 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Redirect } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu, Header } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import ContextMenu from "components/ContextMenu";
import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
label?: React.Node,
collections: CollectionsStore,
policies: PoliciesStore,
t: TFunction,
};
function NewTemplateMenu() {
const menu = useMenuState();
const { t } = useTranslation();
const { collections, policies } = useStores();
@observer
class NewTemplateMenu extends React.Component<Props> {
@observable redirectTo: ?string;
componentDidUpdate() {
this.redirectTo = undefined;
}
handleNewDocument = (collectionId: string) => {
this.redirectTo = newDocumentUrl(collectionId, {
template: true,
});
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { collections, policies, label, t, ...rest } = this.props;
return (
<DropdownMenu
label={
label || (
<Button icon={<PlusIcon />} small>
{t("New template…")}
</Button>
)
}
{...rest}
>
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button icon={<PlusIcon />} {...props} small>
{t("New template")}
</Button>
)}
</MenuButton>
<ContextMenu aria-label={t("New template")} {...menu}>
<Header>{t("Choose a collection")}</Header>
<DropdownMenuItems
<Template
{...menu}
items={collections.orderedData.map((collection) => ({
onClick: () => this.handleNewDocument(collection.id),
to: newDocumentUrl(collection.id, {
template: true,
}),
disabled: !policies.abilities(collection.id).update,
title: (
<>
<Flex align="center">
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
}))}
/>
</DropdownMenu>
);
}
</ContextMenu>
</>
);
}
export default withTranslation()<NewTemplateMenu>(
inject("collections", "policies")(NewTemplateMenu)
);
const CollectionName = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(NewTemplateMenu);
+50 -49
View File
@@ -1,68 +1,69 @@
// @flow
import { inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import UiStore from "stores/UiStore";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import Document from "models/Document";
import Revision from "models/Revision";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import useStores from "hooks/useStores";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
onOpen?: () => void,
onClose: () => void,
history: RouterHistory,
type Props = {|
document: Document,
revision: Revision,
iconColor?: string,
className?: string,
label: React.Node,
ui: UiStore,
t: TFunction,
};
|};
class RevisionMenu extends React.Component<Props> {
handleRestore = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await this.props.document.restore({ revisionId: this.props.revision.id });
const { t } = this.props;
this.props.ui.showToast(t("Document restored"));
this.props.history.push(this.props.document.url);
};
function RevisionMenu({ document, revision, className, iconColor }: Props) {
const { ui } = useStores();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const history = useHistory();
handleCopy = () => {
const { t } = this.props;
this.props.ui.showToast(t("Link copied"));
};
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await document.restore({ revisionId: revision.id });
ui.showToast(t("Document restored"), { type: "success" });
history.push(document.url);
},
[history, ui, t, document, revision]
);
render() {
const { className, label, onOpen, onClose, t } = this.props;
const url = `${window.location.origin}${documentHistoryUrl(
this.props.document,
this.props.revision.id
)}`;
const handleCopy = React.useCallback(() => {
ui.showToast(t("Link copied"), { type: "info" });
}, [ui, t]);
return (
<DropdownMenu
onOpen={onOpen}
onClose={onClose}
const url = `${window.location.origin}${documentHistoryUrl(
document,
revision.id
)}`;
return (
<>
<OverflowMenuButton
className={className}
label={label}
>
<DropdownMenuItem onClick={this.handleRestore}>
iconColor={iconColor}
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Revision options")}>
<MenuItem {...menu} onClick={handleRestore}>
{t("Restore version")}
</DropdownMenuItem>
<hr />
<CopyToClipboard text={url} onCopy={this.handleCopy}>
<DropdownMenuItem>{t("Copy link")}</DropdownMenuItem>
</MenuItem>
<Separator />
<CopyToClipboard text={url} onCopy={handleCopy}>
<MenuItem {...menu}>{t("Copy link")}</MenuItem>
</CopyToClipboard>
</DropdownMenu>
);
}
</ContextMenu>
</>
);
}
export default withTranslation()<RevisionMenu>(
withRouter(inject("ui")(RevisionMenu))
);
export default observer(RevisionMenu);
+49 -55
View File
@@ -1,75 +1,69 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Redirect } from "react-router-dom";
import SharesStore from "stores/SharesStore";
import UiStore from "stores/UiStore";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import Share from "models/Share";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "components/CopyToClipboard";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import useStores from "hooks/useStores";
type Props = {
onOpen?: () => void,
onClose: () => void,
shares: SharesStore,
ui: UiStore,
share: Share,
t: TFunction,
};
@observer
class ShareMenu extends React.Component<Props> {
@observable redirectTo: ?string;
function ShareMenu({ share }: Props) {
const menu = useMenuState({ modal: true });
const { ui, shares } = useStores();
const { t } = useTranslation();
const history = useHistory();
componentDidUpdate() {
this.redirectTo = undefined;
}
const handleGoToDocument = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
history.push(share.documentUrl);
},
[history, share]
);
handleGoToDocument = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.redirectTo = this.props.share.documentUrl;
};
const handleRevoke = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
handleRevoke = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
try {
await shares.revoke(share);
ui.showToast(t("Share link revoked"), { type: "info" });
} catch (err) {
ui.showToast(err.message, { type: "error" });
}
},
[t, shares, share, ui]
);
try {
await this.props.shares.revoke(this.props.share);
const { t } = this.props;
this.props.ui.showToast(t("Share link revoked"));
} catch (err) {
this.props.ui.showToast(err.message);
}
};
const handleCopy = React.useCallback(() => {
ui.showToast(t("Share link copied"), { type: "info" });
}, [t, ui]);
handleCopy = () => {
const { t } = this.props;
this.props.ui.showToast(t("Share link copied"));
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { share, onOpen, onClose, t } = this.props;
return (
<DropdownMenu onOpen={onOpen} onClose={onClose}>
<CopyToClipboard text={share.url} onCopy={this.handleCopy}>
<DropdownMenuItem>{t("Copy link")}</DropdownMenuItem>
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Share options")}>
<CopyToClipboard text={share.url} onCopy={handleCopy}>
<MenuItem {...menu}>{t("Copy link")}</MenuItem>
</CopyToClipboard>
<DropdownMenuItem onClick={this.handleGoToDocument}>
<MenuItem {...menu} onClick={handleGoToDocument}>
{t("Go to document")}
</DropdownMenuItem>
</MenuItem>
<hr />
<DropdownMenuItem onClick={this.handleRevoke}>
<MenuItem {...menu} onClick={handleRevoke}>
{t("Revoke link")}
</DropdownMenuItem>
</DropdownMenu>
);
}
</MenuItem>
</ContextMenu>
</>
);
}
export default withTranslation()<ShareMenu>(inject("shares", "ui")(ShareMenu));
export default observer(ShareMenu);
+30 -32
View File
@@ -1,42 +1,42 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import Document from "models/Document";
import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import useStores from "hooks/useStores";
type Props = {
type Props = {|
document: Document,
documents: DocumentsStore,
t: TFunction,
};
|};
@observer
class TemplatesMenu extends React.Component<Props> {
render() {
const { documents, document, t, ...rest } = this.props;
const templates = documents.templatesInCollection(document.collectionId);
function TemplatesMenu({ document }: Props) {
const menu = useMenuState({ modal: true });
const { documents } = useStores();
const { t } = useTranslation();
const templates = documents.templatesInCollection(document.collectionId);
if (!templates.length) {
return null;
}
if (!templates.length) {
return null;
}
return (
<DropdownMenu
position="left"
label={
<Button disclosure neutral>
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button {...props} disclosure neutral>
{t("Templates")}
</Button>
}
{...rest}
>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Templates")}>
{templates.map((template) => (
<DropdownMenuItem
<MenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
>
@@ -48,17 +48,15 @@ class TemplatesMenu extends React.Component<Props> {
{t("By {{ author }}", { author: template.createdBy.name })}
</Author>
</div>
</DropdownMenuItem>
</MenuItem>
))}
</DropdownMenu>
);
}
</ContextMenu>
</>
);
}
const Author = styled.div`
font-size: 13px;
`;
export default withTranslation()<TemplatesMenu>(
inject("documents")(TemplatesMenu)
);
export default observer(TemplatesMenu);
+92 -80
View File
@@ -1,123 +1,135 @@
// @flow
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import UsersStore from "stores/UsersStore";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import User from "models/User";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import useStores from "hooks/useStores";
type Props = {
type Props = {|
user: User,
users: UsersStore,
t: TFunction,
};
|};
@observer
class UserMenu extends React.Component<Props> {
handlePromote = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { user, users, t } = this.props;
if (
!window.confirm(
t(
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
{ userName: user.name }
function UserMenu({ user }: Props) {
const { users } = useStores();
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
const handlePromote = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
if (
!window.confirm(
t(
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
{ userName: user.name }
)
)
)
) {
return;
}
users.promote(user);
};
) {
return;
}
users.promote(user);
},
[users, user, t]
);
handleDemote = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { user, users, t } = this.props;
if (
!window.confirm(
t("Are you sure you want to make {{ userName }} a member?", {
userName: user.name,
})
)
) {
return;
}
users.demote(user);
};
handleSuspend = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { user, users, t } = this.props;
if (
!window.confirm(
t(
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
const handleDemote = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
if (
!window.confirm(
t("Are you sure you want to make {{ userName }} a member?", {
userName: user.name,
})
)
)
) {
return;
}
users.suspend(user);
};
) {
return;
}
users.demote(user);
},
[users, user, t]
);
handleRevoke = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { user, users } = this.props;
users.delete(user, { confirmation: true });
};
const handleSuspend = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
if (
!window.confirm(
t(
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
)
)
) {
return;
}
users.suspend(user);
},
[users, user, t]
);
handleActivate = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { user, users } = this.props;
users.activate(user);
};
const handleRevoke = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
users.delete(user, { confirmation: true });
},
[users, user]
);
render() {
const { user, t } = this.props;
const handleActivate = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
users.activate(user);
},
[users, user]
);
return (
<DropdownMenu>
<DropdownMenuItems
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("User options")}>
<Template
{...menu}
items={[
{
title: t("Make {{ userName }} a member…", {
userName: user.name,
}),
onClick: this.handleDemote,
onClick: handleDemote,
visible: user.isAdmin,
},
{
title: t("Make {{ userName }} an admin…", {
userName: user.name,
}),
onClick: this.handlePromote,
onClick: handlePromote,
visible: !user.isAdmin && !user.isSuspended,
},
{
type: "separator",
},
{
title: t("Revoke invite"),
onClick: this.handleRevoke,
title: `${t("Revoke invite")}`,
onClick: handleRevoke,
visible: user.isInvited,
},
{
title: t("Activate account"),
onClick: this.handleActivate,
onClick: handleActivate,
visible: !user.isInvited && user.isSuspended,
},
{
title: t("Suspend account"),
onClick: this.handleSuspend,
title: `${t("Suspend account")}`,
onClick: handleSuspend,
visible: !user.isInvited && !user.isSuspended,
},
]}
/>
</DropdownMenu>
);
}
</ContextMenu>
</>
);
}
export default withTranslation()<UserMenu>(inject("users")(UserMenu));
export default observer(UserMenu);
+10 -3
View File
@@ -1,5 +1,5 @@
// @flow
import { pick } from "lodash";
import { pick, trim } from "lodash";
import { action, computed, observable } from "mobx";
import BaseModel from "models/BaseModel";
import Document from "models/Document";
@@ -20,6 +20,7 @@ export default class Collection extends BaseModel {
createdAt: ?string;
updatedAt: ?string;
deletedAt: ?string;
sort: { field: string, direction: "asc" | "desc" };
url: string;
@computed
@@ -45,6 +46,11 @@ export default class Collection extends BaseModel {
return results;
}
@computed
get hasDescription(): boolean {
return !!trim(this.description, "\\").trim();
}
@action
updateDocument(document: Document) {
const travelDocuments = (documentList, path) =>
@@ -79,12 +85,12 @@ export default class Collection extends BaseModel {
return result;
}
pathToDocument(document: Document) {
pathToDocument(documentId: string) {
let path;
const traveler = (nodes, previousPath) => {
nodes.forEach((childNode) => {
const newPath = [...previousPath, childNode];
if (childNode.id === document.id) {
if (childNode.id === documentId) {
path = newPath;
return;
}
@@ -108,6 +114,7 @@ export default class Collection extends BaseModel {
"description",
"icon",
"private",
"sort",
]);
};
+2 -2
View File
@@ -207,7 +207,7 @@ export default class Document extends BaseModel {
@action
view = () => {
// we don't record views for documents in the trash
if (this.isDeleted) {
if (this.isDeleted || !this.publishedAt) {
return;
}
@@ -268,7 +268,7 @@ export default class Document extends BaseModel {
};
move = (collectionId: string, parentDocumentId: ?string) => {
return this.store.move(this, collectionId, parentDocumentId);
return this.store.move(this.id, collectionId, parentDocumentId);
};
duplicate = () => {
+20 -6
View File
@@ -2,7 +2,7 @@
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon } from "outline-icons";
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
@@ -144,7 +144,7 @@ class CollectionScene extends React.Component<Props> {
<Action>
<InputSearch
source="collection"
placeholder={t("Search in collection")}
placeholder={`${t("Search in collection")}`}
collectionId={match.params.id}
/>
</Action>
@@ -164,7 +164,20 @@ class CollectionScene extends React.Component<Props> {
</>
)}
<Action>
<CollectionMenu collection={this.collection} />
<CollectionMenu
collection={this.collection}
placement="bottom-end"
modal={false}
label={(props) => (
<Button
icon={<MoreIcon />}
{...props}
borderOnHover
neutral
small
/>
)}
/>
</Action>
</Actions>
);
@@ -179,9 +192,10 @@ class CollectionScene extends React.Component<Props> {
const pinnedDocuments = this.collection
? documents.pinnedInCollection(this.collection.id)
: [];
const hasPinnedDocuments = !!pinnedDocuments.length;
const collection = this.collection;
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
const hasDescription = collection ? collection.hasDescription : false;
return (
<CenteredContent>
@@ -207,7 +221,7 @@ class CollectionScene extends React.Component<Props> {
&nbsp;&nbsp;
{collection.private && (
<Button onClick={this.onPermissions} neutral>
{t("Manage members")}
{t("Manage members")}
</Button>
)}
</Wrapper>
@@ -240,7 +254,7 @@ class CollectionScene extends React.Component<Props> {
{collection.name}
</Heading>
{collection.description && (
{hasDescription && (
<React.Suspense fallback={<p>Loading</p>}>
<Editor
id={collection.id}
+1 -1
View File
@@ -32,7 +32,7 @@ class CollectionDelete extends React.Component<Props> {
this.props.history.push(homeUrl());
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message);
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isDeleting = false;
}
+34 -10
View File
@@ -2,7 +2,7 @@
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
@@ -11,6 +11,7 @@ import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputRich from "components/InputRich";
import InputSelect from "components/InputSelect";
import Switch from "components/Switch";
type Props = {
@@ -27,6 +28,8 @@ class CollectionEdit extends React.Component<Props> {
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private;
@observable sort: { field: string, direction: "asc" | "desc" } = this.props
.collection.sort;
@observable isSaving: boolean;
handleSubmit = async (ev: SyntheticEvent<*>) => {
@@ -41,16 +44,27 @@ class CollectionEdit extends React.Component<Props> {
icon: this.icon,
color: this.color,
private: this.private,
sort: this.sort,
});
this.props.onSubmit();
this.props.ui.showToast(t("The collection was updated"));
this.props.ui.showToast(t("The collection was updated"), {
type: "success",
});
} catch (err) {
this.props.ui.showToast(err.message);
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
handleSortChange = (ev: SyntheticInputEvent<HTMLSelectElement>) => {
const [field, direction] = ev.target.value.split(".");
if (direction === "asc" || direction === "desc") {
this.sort = { field, direction };
}
};
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
@@ -75,9 +89,10 @@ class CollectionEdit extends React.Component<Props> {
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
{t(
"You can edit the name and other details at any time, however doing so often might confuse your team mates."
)}
<Trans>
You can edit the name and other details at any time, however doing
so often might confuse your team mates.
</Trans>
</HelpText>
<Flex>
<Input
@@ -105,6 +120,15 @@ class CollectionEdit extends React.Component<Props> {
minHeight={68}
maxHeight={200}
/>
<InputSelect
label={t("Sort in sidebar")}
options={[
{ label: t("Alphabetical"), value: "title.asc" },
{ label: t("Manual sort"), value: "index.asc" },
]}
value={`${this.sort.field}.${this.sort.direction}`}
onChange={this.handleSortChange}
/>
<Switch
id="private"
label={t("Private collection")}
@@ -112,15 +136,15 @@ class CollectionEdit extends React.Component<Props> {
checked={this.private}
/>
<HelpText>
{t(
"A private collection will only be visible to invited team members."
)}
<Trans>
A private collection will only be visible to invited team members.
</Trans>
</HelpText>
<Button
type="submit"
disabled={this.isSaving || !this.props.collection.name}
>
{this.isSaving ? t("Saving") : t("Save")}
{this.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Flex>
@@ -67,10 +67,11 @@ class AddGroupsToCollection extends React.Component<Props> {
this.props.ui.showToast(
t("{{ groupName }} was added to the collection", {
groupName: group.name,
})
}),
{ type: "success" }
);
} catch (err) {
this.props.ui.showToast(t("Could not add user"));
this.props.ui.showToast(t("Could not add user"), { type: "error" });
console.error(err);
}
};
@@ -92,7 +93,7 @@ class AddGroupsToCollection extends React.Component<Props> {
<Input
type="search"
placeholder={t("Search by group name")}
placeholder={`${t("Search by group name")}`}
value={this.query}
onChange={this.handleFilter}
label={t("Search groups")}
@@ -62,10 +62,13 @@ class AddPeopleToCollection extends React.Component<Props> {
permission: "read_write",
});
this.props.ui.showToast(
t("{{ userName }} was added to the collection", { userName: user.name })
t("{{ userName }} was added to the collection", {
userName: user.name,
}),
{ type: "success" }
);
} catch (err) {
this.props.ui.showToast(t("Could not add user"));
this.props.ui.showToast(t("Could not add user"), { type: "error" });
}
};
@@ -86,7 +89,7 @@ class AddPeopleToCollection extends React.Component<Props> {
<Input
type="search"
placeholder={t("Search by name")}
placeholder={`${t("Search by name")}`}
value={this.query}
onChange={this.handleFilter}
label={t("Search people")}
@@ -61,9 +61,11 @@ class CollectionMembers extends React.Component<Props> {
collectionId: this.props.collection.id,
userId: user.id,
});
this.props.ui.showToast(`${user.name} was removed from the collection`);
this.props.ui.showToast(`${user.name} was removed from the collection`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not remove user");
this.props.ui.showToast("Could not remove user", { type: "error" });
}
};
@@ -74,9 +76,11 @@ class CollectionMembers extends React.Component<Props> {
userId: user.id,
permission,
});
this.props.ui.showToast(`${user.name} permissions were updated`);
this.props.ui.showToast(`${user.name} permissions were updated`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not update user");
this.props.ui.showToast("Could not update user", { type: "error" });
}
};
@@ -86,9 +90,11 @@ class CollectionMembers extends React.Component<Props> {
collectionId: this.props.collection.id,
groupId: group.id,
});
this.props.ui.showToast(`${group.name} was removed from the collection`);
this.props.ui.showToast(`${group.name} was removed from the collection`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not remove group");
this.props.ui.showToast("Could not remove group", { type: "error" });
}
};
@@ -99,9 +105,11 @@ class CollectionMembers extends React.Component<Props> {
groupId: group.id,
permission,
});
this.props.ui.showToast(`${group.name} permissions were updated`);
this.props.ui.showToast(`${group.name} permissions were updated`, {
type: "success",
});
} catch (err) {
this.props.ui.showToast("Could not update user");
this.props.ui.showToast("Could not update user", { type: "error" });
}
};
@@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import CollectionGroupMembership from "models/CollectionGroupMembership";
import Group from "models/Group";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import GroupListItem from "components/GroupListItem";
import InputSelect from "components/InputSelect";
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
type Props = {
group: Group,
@@ -50,15 +50,10 @@ const MemberListItem = ({
labelHidden
/>
<ButtonWrap>
<DropdownMenu>
<DropdownMenuItem onClick={openMembersModal}>
{t("Members…")}
</DropdownMenuItem>
<hr />
<DropdownMenuItem onClick={onRemove}>
{t("Remove")}
</DropdownMenuItem>
</DropdownMenu>
<CollectionGroupMemberMenu
onMembers={openMembersModal}
onRemove={onRemove}
/>
</ButtonWrap>
</>
)}
@@ -1,17 +1,17 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Membership from "models/Membership";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Flex from "components/Flex";
import InputSelect from "components/InputSelect";
import ListItem from "components/List/Item";
import Time from "components/Time";
import MemberMenu from "menus/MemberMenu";
type Props = {
user: User,
@@ -46,11 +46,9 @@ const MemberListItem = ({
subtitle={
<>
{user.lastActiveAt ? (
<>
{t("Active {{ lastActiveAt }} ago", {
lastActiveAt: <Time dateTime={user.lastActiveAt} />,
})}
</>
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Never signed in")
)}
@@ -71,13 +69,7 @@ const MemberListItem = ({
/>
)}
&nbsp;&nbsp;
{canEdit && onRemove && (
<DropdownMenu>
<DropdownMenuItem onClick={onRemove}>
{t("Remove")}
</DropdownMenuItem>
</DropdownMenu>
)}
{canEdit && onRemove && <MemberMenu onRemove={onRemove} />}
{canEdit && onAdd && (
<Button onClick={onAdd} neutral>
{t("Add")}
@@ -1,7 +1,7 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
@@ -25,11 +25,9 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
subtitle={
<>
{user.lastActiveAt ? (
<>
{t("Active {{ lastActiveAt }} ago", {
lastActiveAt: <Time dateTime={user.lastActiveAt} />,
})}
</>
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Never signed in")
)}
+2 -2
View File
@@ -53,7 +53,7 @@ class CollectionNew extends React.Component<Props> {
this.props.onSubmit();
this.props.history.push(collection.url);
} catch (err) {
this.props.ui.showToast(err.message);
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
@@ -148,7 +148,7 @@ class CollectionNew extends React.Component<Props> {
</HelpText>
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? t("Creating") : t("Create")}
{this.isSaving ? `${t("Creating")}` : t("Create")}
</Button>
</form>
);
+2 -3
View File
@@ -1,6 +1,5 @@
// @flow
import useWindowScrollPosition from "@rehooks/window-scroll-position";
import { darken } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -72,7 +71,7 @@ const Wrapper = styled("div")`
position: sticky;
top: 80px;
box-shadow: 1px 0 0 ${(props) => darken(0.05, props.theme.sidebarBackground)};
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
margin-top: 40px;
margin-right: 2em;
min-height: 40px;
@@ -108,7 +107,7 @@ const ListItem = styled("li")`
padding-right: 2em;
line-height: 1.3;
border-right: 3px solid
${(props) => (props.active ? props.theme.textSecondary : "transparent")};
${(props) => (props.active ? props.theme.divider : "transparent")};
`;
const Link = styled("a")`
@@ -240,13 +240,11 @@ class DataLoader extends React.Component<Props> {
}
const abilities = policies.abilities(document.id);
const key = this.isEditing ? "editing" : "read-only";
return (
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
key={key}
document={document}
revision={revision}
abilities={abilities}
+10 -8
View File
@@ -33,6 +33,7 @@ import References from "./References";
import { type LocationWithState, type Theme } from "types";
import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji";
import { meta } from "utils/keyboard";
import {
collectionUrl,
documentMoveUrl,
@@ -80,13 +81,13 @@ class DocumentScene extends React.Component<Props> {
@observable title: string = this.props.document.title;
getEditorText: () => string = () => this.props.document.text;
componentDidMount() {
this.updateIsDirty();
}
componentDidUpdate(prevProps) {
const { auth, document } = this.props;
if (prevProps.readOnly && !this.props.readOnly) {
this.updateIsDirty();
}
if (this.props.readOnly) {
this.lastRevision = document.revision;
@@ -99,6 +100,7 @@ class DocumentScene extends React.Component<Props> {
`Document updated by ${document.updatedBy.name}`,
{
timeout: 30 * 1000,
type: "warning",
action: {
text: "Reload",
onClick: () => {
@@ -163,7 +165,7 @@ class DocumentScene extends React.Component<Props> {
}
}
@keydown("meta+shift+p")
@keydown(`${meta}+shift+p`)
onPublish(ev) {
ev.preventDefault();
const { document } = this.props;
@@ -171,7 +173,7 @@ class DocumentScene extends React.Component<Props> {
this.onSave({ publish: true, done: true });
}
@keydown("meta+ctrl+h")
@keydown(`${meta}+ctrl+h`)
onToggleTableOfContents(ev) {
if (!this.props.readOnly) return;
@@ -238,7 +240,7 @@ class DocumentScene extends React.Component<Props> {
this.props.ui.setActiveDocument(savedDocument);
}
} catch (err) {
this.props.ui.showToast(err.message);
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
this.isPublishing = false;
@@ -438,7 +440,7 @@ class DocumentScene extends React.Component<Props> {
ui={this.props.ui}
/>
</Flex>
{readOnly && !isShare && !revision && (
{!isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
@@ -86,7 +86,7 @@ class DocumentMove extends React.Component<Props> {
}
handleSuccess = () => {
this.props.ui.showToast("Document moved");
this.props.ui.showToast("Document moved", { type: "info" });
this.props.onRequestClose();
};
+49 -15
View File
@@ -12,6 +12,8 @@ import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor from "components/Editor";
import Flex from "components/Flex";
import HoverPreview from "components/HoverPreview";
import Star, { AnimatedStar } from "components/Star";
import { isMetaKey } from "utils/keyboard";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
@@ -53,7 +55,7 @@ class DocumentEditor extends React.Component<Props> {
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
if (event.key === "Enter") {
event.preventDefault();
if (event.metaKey) {
if (isMetaKey(event)) {
this.props.onSave({ done: true });
return;
}
@@ -67,12 +69,12 @@ class DocumentEditor extends React.Component<Props> {
this.focusAtStart();
return;
}
if (event.key === "p" && event.metaKey && event.shiftKey) {
if (event.key === "p" && isMetaKey(event) && event.shiftKey) {
event.preventDefault();
this.props.onSave({ publish: true, done: true });
return;
}
if (event.key === "s" && event.metaKey) {
if (event.key === "s" && isMetaKey(event)) {
event.preventDefault();
this.props.onSave({});
return;
@@ -97,23 +99,35 @@ class DocumentEditor extends React.Component<Props> {
readOnly,
innerRef,
} = this.props;
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
const normalizedTitle =
!title && readOnly ? document.titleWithDefault : title;
return (
<Flex auto column>
<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={MAX_TITLE_LENGTH}
/>
{readOnly ? (
<Title
as="div"
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
$isStarred={document.isStarred}
>
<span>{normalizedTitle}</span>{" "}
{!isShare && <StarButton document={document} size={32} />}
</Title>
) : (
<Title
type="text"
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder={document.placeholder}
value={normalizedTitle}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
autoFocus={!title}
maxLength={MAX_TITLE_LENGTH}
/>
)}
<DocumentMetaWithViews
isDraft={isDraft}
document={document}
@@ -141,11 +155,17 @@ class DocumentEditor extends React.Component<Props> {
}
}
const StarButton = styled(Star)`
position: relative;
top: 4px;
`;
const Title = styled(Textarea)`
z-index: 1;
line-height: 1.25;
margin-top: 1em;
margin-bottom: 0.5em;
margin-left: ${(props) => (props.$startsWithEmojiAndSpace ? "-1.2em" : 0)};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
color: ${(props) => props.theme.text};
@@ -161,6 +181,20 @@ const Title = styled(Textarea)`
color: ${(props) => props.theme.placeholder};
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
}
${AnimatedStar} {
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
}
&:hover {
${AnimatedStar} {
opacity: 0.5;
&:hover {
opacity: 1;
}
}
}
`;
export default DocumentEditor;
+20 -29
View File
@@ -12,7 +12,7 @@ import {
import { transparentize, darken } from "polished";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect } from "react-router-dom";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
@@ -34,7 +34,7 @@ import Tooltip from "components/Tooltip";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { meta } from "utils/keyboard";
import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
type Props = {
@@ -63,7 +63,6 @@ type Props = {
class Header extends React.Component<Props> {
@observable isScrolled = false;
@observable showShareModal = false;
@observable redirectTo: ?string;
componentDidMount() {
window.addEventListener("scroll", this.handleScroll);
@@ -79,18 +78,6 @@ class Header extends React.Component<Props> {
handleScroll = throttle(this.updateIsScrolled, 50);
handleEdit = () => {
this.redirectTo = editDocumentUrl(this.props.document);
};
handleNewFromTemplate = () => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
templateId: document.id,
});
};
handleSave = () => {
this.props.onSave({ done: true });
};
@@ -118,8 +105,6 @@ class Header extends React.Component<Props> {
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const {
shares,
document,
@@ -172,7 +157,7 @@ class Header extends React.Component<Props> {
tooltip={
ui.tocVisible ? t("Hide contents") : t("Show contents")
}
shortcut={`ctrl+${meta}+h`}
shortcut={`ctrl+${metaDisplay}+h`}
delay={250}
placement="bottom"
>
@@ -203,7 +188,7 @@ class Header extends React.Component<Props> {
<Wrapper align="center" justify="flex-end">
{isSaving && !isPublishing && (
<Action>
<Status>{t("Saving")}</Status>
<Status>{t("Saving")}</Status>
</Action>
)}
&nbsp;
@@ -250,7 +235,7 @@ class Header extends React.Component<Props> {
<Action>
<Tooltip
tooltip={t("Save")}
shortcut={`${meta}+enter`}
shortcut={`${metaDisplay}+enter`}
delay={500}
placement="bottom"
>
@@ -276,8 +261,9 @@ class Header extends React.Component<Props> {
placement="bottom"
>
<Button
as={Link}
icon={<EditIcon />}
onClick={this.handleEdit}
to={editDocumentUrl(this.props.document)}
neutral
small
>
@@ -290,18 +276,18 @@ class Header extends React.Component<Props> {
<Action>
<NewChildDocumentMenu
document={document}
label={
label={(props) => (
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button icon={<PlusIcon />} neutral>
<Button icon={<PlusIcon />} {...props} neutral>
{t("New doc")}
</Button>
</Tooltip>
}
)}
/>
</Action>
)}
@@ -309,7 +295,10 @@ class Header extends React.Component<Props> {
<Action>
<Button
icon={<PlusIcon />}
onClick={this.handleNewFromTemplate}
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
primary
small
>
@@ -321,7 +310,7 @@ class Header extends React.Component<Props> {
<Action>
<Tooltip
tooltip={t("Publish")}
shortcut={`${meta}+shift+p`}
shortcut={`${metaDisplay}+shift+p`}
delay={500}
placement="bottom"
>
@@ -331,7 +320,7 @@ class Header extends React.Component<Props> {
disabled={publishingIsDisabled}
small
>
{isPublishing ? t("Publishing") : t("Publish")}
{isPublishing ? `${t("Publishing")}` : t("Publish")}
</Button>
</Tooltip>
</Action>
@@ -343,15 +332,16 @@ class Header extends React.Component<Props> {
<DocumentMenu
document={document}
isRevision={isRevision}
label={
label={(props) => (
<Button
icon={<MoreIcon />}
iconColor="currentColor"
{...props}
borderOnHover
neutral
small
/>
}
)}
showToggleEmbeds={canToggleEmbeds}
showPrint
/>
@@ -422,6 +412,7 @@ const Title = styled.div`
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: pointer;
display: none;
width: 0;
@@ -16,8 +16,9 @@ class MarkAsViewed extends React.Component<Props> {
const { document } = this.props;
this.viewTimeout = setTimeout(async () => {
if (document.publishedAt) {
const view = await document.view();
const view = await document.view();
if (view) {
document.updateLastViewed(view);
}
}, MARK_AS_VIEWED_AFTER);
+93 -59
View File
@@ -1,84 +1,118 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Document from "models/Document";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
import { collectionUrl, documentUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
document: Document,
documents: DocumentsStore,
ui: UiStore,
onSubmit: () => void,
};
@observer
class DocumentDelete extends React.Component<Props> {
@observable isDeleting: boolean;
function DocumentDelete({ document, onSubmit }: Props) {
const { t } = useTranslation();
const { ui, documents } = useStores();
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const { showToast } = ui;
const canArchive = !document.isDraft && !document.isArchived;
handleSubmit = async (ev: SyntheticEvent<>) => {
const { documents, document } = this.props;
ev.preventDefault();
this.isDeleting = true;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setDeleting(true);
try {
await document.delete();
try {
await document.delete();
// only redirect if we're currently viewing the document that's deleted
if (this.props.ui.activeDocumentId === document.id) {
// If the document has a parent and it's available in the store then
// redirect to it
if (document.parentDocumentId) {
const parent = documents.get(document.parentDocumentId);
if (parent) {
this.props.history.push(documentUrl(parent));
return;
// only redirect if we're currently viewing the document that's deleted
if (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) {
history.push(documentUrl(parent));
return;
}
}
// otherwise, redirect to the collection home
history.push(collectionUrl(document.collectionId));
}
// otherwise, redirect to the collection home
this.props.history.push(collectionUrl(document.collectionId));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setDeleting(false);
}
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message);
} finally {
this.isDeleting = false;
}
};
},
[showToast, onSubmit, ui, document, documents, history]
);
render() {
const { document } = this.props;
const handleArchive = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setArchiving(true);
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the{" "}
<strong>{document.titleWithDefault}</strong> {document.noun} will
delete all of its history
{document.isTemplate ? "" : ", and any nested documents"}.
</HelpText>
{!document.isDraft && !document.isArchived && (
<HelpText>
If youd like the option of referencing or restoring this{" "}
{document.noun} in the future, consider archiving it instead.
</HelpText>
try {
await document.archive();
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setArchiving(false);
}
},
[showToast, onSubmit, document]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
{document.isTemplate ? (
<Trans>
Are you sure you want to delete the{" "}
<strong>{{ documentTitle: document.titleWithDefault }}</strong>{" "}
template?
</Trans>
) : (
<Trans>
Are you sure about that? Deleting the{" "}
<strong>{{ documentTitle: document.titleWithDefault }}</strong>{" "}
document will delete all of its history and any nested documents.
</Trans>
)}
<Button type="submit" danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}
</HelpText>
{canArchive && (
<HelpText>
<Trans>
If youd like the option of referencing or restoring the{" "}
{{ noun: document.noun }} in the future, consider archiving it
instead.
</Trans>
</HelpText>
)}
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
&nbsp;&nbsp;
{canArchive && (
<Button type="button" onClick={handleArchive} neutral>
{isArchiving ? `${t("Archiving")}` : t("Archive")}
</Button>
</form>
</Flex>
);
}
)}
</form>
</Flex>
);
}
export default inject("documents", "ui")(withRouter(DocumentDelete));
export default observer(DocumentDelete);
+3 -1
View File
@@ -37,7 +37,9 @@ class DocumentNew extends React.Component<Props> {
});
this.props.history.replace(editDocumentUrl(document));
} catch (err) {
this.props.ui.showToast("Couldnt create the document, try again?");
this.props.ui.showToast("Couldnt create the document, try again?", {
type: "error",
});
this.props.history.goBack();
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ class DocumentShare extends React.Component<Props> {
try {
await share.save({ published: event.target.checked });
} catch (err) {
this.props.ui.showToast(err.message);
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
+4 -2
View File
@@ -28,10 +28,12 @@ class DocumentTemplatize extends React.Component<Props> {
try {
const template = await this.props.document.templatize();
this.props.history.push(documentUrl(template));
this.props.ui.showToast("Template created, go ahead and customize it");
this.props.ui.showToast("Template created, go ahead and customize it", {
type: "info",
});
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message);
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
+1 -1
View File
@@ -30,7 +30,7 @@ class GroupDelete extends React.Component<Props> {
this.props.history.push(groupSettings());
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message);
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isDeleting = false;
}
+1 -1
View File
@@ -30,7 +30,7 @@ class GroupEdit extends React.Component<Props> {
await this.props.group.save({ name: this.name });
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message);
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
+4 -4
View File
@@ -62,10 +62,11 @@ class AddPeopleToGroup extends React.Component<Props> {
userId: user.id,
});
this.props.ui.showToast(
t(`{{userName}} was added to the group`, { userName: user.name })
t(`{{userName}} was added to the group`, { userName: user.name }),
{ type: "success" }
);
} catch (err) {
this.props.ui.showToast(t("Could not add user"));
this.props.ui.showToast(t("Could not add user"), { type: "error" });
}
};
@@ -88,7 +89,7 @@ class AddPeopleToGroup extends React.Component<Props> {
<Input
type="search"
placeholder={t("Search by name")}
placeholder={`${t("Search by name")}`}
value={this.query}
onChange={this.handleFilter}
label={t("Search people")}
@@ -111,7 +112,6 @@ class AddPeopleToGroup extends React.Component<Props> {
key={item.id}
user={item}
onAdd={() => this.handleAddUser(item)}
canEdit
/>
)}
/>
+4 -4
View File
@@ -52,10 +52,11 @@ class GroupMembers extends React.Component<Props> {
userId: user.id,
});
this.props.ui.showToast(
t(`{{userName}} was removed from the group`, { userName: user.name })
t(`{{userName}} was removed from the group`, { userName: user.name }),
{ type: "success" }
);
} catch (err) {
this.props.ui.showToast(t("Could not remove user"));
this.props.ui.showToast(t("Could not remove user"), { type: "error" });
}
};
@@ -82,7 +83,7 @@ class GroupMembers extends React.Component<Props> {
icon={<PlusIcon />}
neutral
>
{t("Add people")}
{t("Add people")}
</Button>
</span>
</>
@@ -102,7 +103,6 @@ class GroupMembers extends React.Component<Props> {
<GroupMemberListItem
key={item.id}
user={item}
membership={groupMemberships.get(`${item.id}-${group.id}`)}
onRemove={
can.update ? () => this.handleRemoveUser(item) : undefined
}
@@ -1,28 +1,21 @@
// @flow
import * as React from "react";
import GroupMembership from "models/GroupMembership";
import User from "models/User";
import Avatar from "components/Avatar";
import Badge from "components/Badge";
import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Flex from "components/Flex";
import ListItem from "components/List/Item";
import Time from "components/Time";
import GroupMemberMenu from "menus/GroupMemberMenu";
type Props = {
type Props = {|
user: User,
groupMembership?: ?GroupMembership,
onAdd?: () => Promise<void>,
onRemove?: () => Promise<void>,
};
|};
const GroupMemberListItem = ({
user,
groupMembership,
onRemove,
onAdd,
}: Props) => {
const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
return (
<ListItem
title={user.name}
@@ -42,11 +35,7 @@ const GroupMemberListItem = ({
image={<Avatar src={user.avatarUrl} size={40} />}
actions={
<Flex align="center">
{onRemove && (
<DropdownMenu>
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
</DropdownMenu>
)}
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
{onAdd && (
<Button onClick={onAdd} neutral>
Add

Some files were not shown because too many files have changed in this diff Show More