Compare commits

...

125 Commits

Author SHA1 Message Date
Tom Moor 004cf59988 stash 2021-02-10 19:56:59 -08:00
Tom Moor df472ac391 feat: add total users to people management screen (#1878)
* feat: add total users to pagination

* move this.total in runInAction callback

* add total counts + counts to people tabs

* progress: use raw pg query

* progress: add test

* fix: SQL interpolation

* Styling and translation of People page

Co-authored-by: Tim <timothychang94@gmail.com>
2021-02-09 20:13:09 -08:00
Tom Moor 097359bf7c feat: Added ability to disable sharing at collection (#1875)
* feat: Added ability to disable sharing at collection

* fix: Disable all previous share links when disabling collection share
Language

* fix: Disable document sharing for read-only collection members

* wip

* test

* fix: Clear policies after updating sharing settings

* chore: Less ambiguous language

* feat: Allow setting sharing choice on collection creation
2021-02-09 19:04:03 -08:00
Tom Moor cc90c8de1c feat: Sidebar Improvements (#1862)
* wip

* refactor behaviorg

* stash

* simplify
2021-02-07 21:51:56 -08:00
Tom Moor ac6c48817c fix: Unable to select .md files by default on some machines depending on installed software 2021-02-07 16:14:03 -08:00
Tom Moor 8e3534dcbc fix: File import via collection menu regression 2021-02-07 16:13:44 -08:00
Yaroslav Zhavoronkov e2d7d34f30 fix: Pass credentials with API requests when required to work with Cloudflare Access (#1867) 2021-02-06 22:49:49 -08:00
Tom Moor 0d88a1dfda Update README.md 2021-02-06 21:49:07 -08:00
Tom Moor df5a2e45c5 chore: Improved deployment documentation (#1868) 2021-02-06 21:33:56 -08:00
Tom Moor 1a7a48674b fix: link in README, add ARCHITECTURE document 2021-02-06 17:46:54 -08:00
Tom Moor e23474fa1c feat: Add parameters for filtering events (#1863)
* feat: Add parameters for filtering events

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

* feat: New tab on collection home

* update tab layout

* fix: Correctly sort index sorted documents.list

* revert: Tab layout changes

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

* chore: More sealed props improvements

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

* support virtual host buckets

* fix

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* add google drawings image

* update google drawings image and regex

* allow query parameter in google drawings regex

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

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

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

* feat: Saving setting, animation

* all requirements, refactoring needed

* lint

* refactor useResize

* some mobile improvements

* fix

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

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

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

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

* csp

* fix: Turns out prefetch-src is still behind a flag in Chrome, not publicly available yet
2021-01-18 15:48:46 -08:00
Tom Moor 27fca28450 fix: Account for rehydrated old users before language
closes #1819
2021-01-17 22:19:54 -08:00
Tom Moor afcce7a0ef fix: Add missing width/height tags to img 2021-01-17 21:49:51 -08:00
Tom Moor f33495dddc fix: Editor mod shortcuts not working on Windows
closes #1745
2021-01-17 18:32:32 -08:00
Tom Moor 51b75fa09d 0.52.0 2021-01-16 14:12:35 -08:00
Tom Moor 522df125aa feat: Add CDN support (#1817)
* chore: CSP

* chore: Optionally use CDN for serving images
2021-01-16 11:12:10 -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
250 changed files with 8008 additions and 4417 deletions
+73 -21
View File
@@ -1,26 +1,40 @@
# Copy this file to .env, remove this comment and change the keys. For development
# with docker this should mostly work out of the box other than setting the Slack
# keys (for auth) and the SECRET_KEY.
#
# Please use `openssl rand -hex 32` to create SECRET_KEY
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# setting the Slack keys and the SECRET_KEY.
# –––––––––––––––– REQUIRED ––––––––––––––––
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
SECRET_KEY=generate_a_new_key
# Generate a unique random key, you can use `openssl rand -hex 32` in terminal
# DO NOT LEAVE UNSET
UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5532/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
REDIS_URL=redis://localhost:6479
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
URL=http://localhost:3000
PORT=3000
# enforce (auto redirect to) https in production, (optional) default is true.
# set to false if your SSL is terminated at a loadbalancer, for example
FORCE_HTTPS=true
# Third party signin credentials, at least one of EITHER Google OR Slack is
# required for a working installation or you'll have no sign-in options.
ENABLE_UPDATES=true
DEBUG=cache,presenters,events
# Third party signin credentials (at least one is required)
# To configure Slack auth, you'll need to create an Application at
# => https://api.slack.com/apps
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
@@ -28,22 +42,59 @@ SLACK_SECRET=get_the_secret_of_above_key
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<your Outline URL>/auth/google.callback
# https://<URL>/auth/google.callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Comma separated list of domains to be allowed (optional)
# If not set, all Google apps domains are allowed by default
# –––––––––––––––– OPTIONAL ––––––––––––––––
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# Have the installation check for updates by sending anonymized statistics to
# the maintainers
ENABLE_UPDATES=true
# You may enable or disable debugging categories to increase the noisiness of
# logs. The default is a good balance
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
GOOGLE_ALLOWED_DOMAINS=
# Third party credentials (optional)
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
# AWS credentials (optional in development)
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# A more detailed guide on setting up S3 is available here:
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
#
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
@@ -51,11 +102,10 @@ AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
# uploaded s3 objects permission level, default is private
# set to "public-read" to allow public access
AWS_S3_ACL=private
# Emails configuration (optional)
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
@@ -66,4 +116,6 @@ SMTP_REPLY_EMAIL=
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
+2
View File
@@ -18,6 +18,7 @@
[options]
emoji=true
sharedmemory.heap_size=3221225472
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=app
@@ -32,6 +33,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 -1
View File
@@ -1,6 +1,7 @@
{
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.validate.enable": false,
"typescript.format.enable": false,
"editor.formatOnSave": true,
"typescript.format.enable": false
}
+66
View File
@@ -0,0 +1,66 @@
# Architecture
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
## Frontend
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [MobX](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
> Important Note: The Outline editor is built on [Prosemirror](https://github.com/prosemirror) and managed in a separate open source repository to encourage re-use: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor).
```
app
├── components - React components reusable across scenes
├── embeds - Embed definitions that represent rich interactive embeds in the editor
├── hooks - Reusable React hooks
├── menus - Context menus, often appear in multiple places in the UI
├── models - State models using MobX observables
├── routes - Route definitions, note that chunks are async loaded with suspense
├── scenes - A scene represents a full-page view that contains several components
├── stores - Collections of models and associated fetch logic
├── types - Flow types
└── utils - Utility methods specific to the frontend
```
## Backend
The API server is driven by [Koa](http://koajs.com/), it uses [Sequelize](http://docs.sequelizejs.com/) as the ORM and Redis with [Bull](https://github.com/OptimalBits/bull) for queues and async event management. Authorization logic
is contained in [cancan](https://www.npmjs.com/package/cancan) policies under the "policies" directory.
Interested in more documentation on the API routes? Check out the [API documentation](https://getoutline.com/developers).
```
server
├── api - All API routes are contained within here
│ └── middlewares - Koa middlewares specific to the API
├── auth - OAuth routes for Slack and Google, plus email authentication routes
├── commands - We are gradually moving to the command pattern for new write logic
├── config - Database configuration
├── emails - Transactional email templates
│ └── components - Shared React components for email templates
├── middlewares - Koa middlewares
├── migrations - Database migrations
├── models - Sequelize models
├── onboarding - Markdown templates for onboarding documents
├── policies - Authorization logic based on cancan
├── presenters - JSON presenters for database models, the interface between backend -> frontend
├── services - Service definitions are triggered for events and perform async jobs
├── static - Static assets
├── test - Test helpers and fixtures, tests themselves are colocated
└── utils - Utility methods specific to the backend
```
## Shared
Where logic is shared between the client and server it is placed in this directory. This is generally
small utilities.
```
shared
├── i18n - Internationalization confiuration
│ └── locales - Language specific translation files
├── styles - Styles, colors and other global aesthetics
├── utils - Shared utility methods
└── constants - Shared constants
```
+76 -77
View File
@@ -19,7 +19,7 @@ This is the source code that runs [**Outline**](https://www.getoutline.com) and
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
## Installation
# Installation
Outline requires the following dependencies:
@@ -31,33 +31,58 @@ Outline requires the following dependencies:
- Slack or Google developer application for authentication
### Production
## Self-Hosted Production
For a manual self-hosted production installation these are the suggested steps:
### Docker
1. Clone this repo and install dependencies with `yarn install`
1. Build the source code with `yarn build`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
For a manual self-hosted production installation these are the recommended steps:
1. First setup Redis and Postgres servers, this is outside the scope of the guide.
1. Download the latest official Docker image, new releases are available around the middle of every month:
`docker pull outlinewiki/outline`
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
`docker run --env-file=.env outlinewiki/outline`
1. Setup the database with `yarn sequelize:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn sequelize:migrate`
1. Start the container:
`docker run outlinewiki/outline`
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
1. (Optional) You can add an `nginx` or other reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
### Terraform
Alternatively a community member maintains a script to deploy Outline on Google Cloud Platform with [Terraform & Ansible](https://github.com/rjsgn/outline-terraform-ansible).
### Upgrading
#### 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:
```shell
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
```
#### Git
If you're running Outline by cloning this repository, run the following command to upgrade:
```shell
yarn run upgrade
```
### Development
## Local Development
In development you can quickly get an environment running using Docker by following these steps:
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
@@ -76,9 +101,28 @@ In development you can quickly get an environment running using Docker by follow
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
## Development
# Contributing
### Server
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Before submitting a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
## Architecture
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
## Debugging
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
@@ -86,52 +130,6 @@ Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging o
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
```
## Migrations
Sequelize is used to create and run migrations, for example:
```
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or to run migrations on test database:
```
yarn sequelize db:migrate --env test
```
## Structure
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
### Frontend
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
- `app/` - Frontend React application
- `app/scenes` - Full page views
- `app/components` - Reusable React components
- `app/stores` - Global state stores
- `app/models` - State models
- `app/types` - Flow types for non-models
### Backend
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
- `server/api` - API endpoints
- `server/commands` - Domain logic, currently being refactored from /models
- `server/emails` - React rendered email templates
- `server/models` - Database models
- `server/policies` - Authorization logic
- `server/presenters` - API responses for database models
- `server/test` - Test helps and support
- `server/utils` - Utility methods
- `shared` - Code shared between frontend and backend applications
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
@@ -157,20 +155,21 @@ yarn test:server
yarn test:app
```
## Contributing
## Migrations
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Sequelize is used to create and run migrations, for example:
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
```
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
Or to run migrations on test database:
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
```
yarn sequelize db:migrate --env test
```
## License
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
Outline is [BSL 1.1 licensed](LICENSE).
-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;
}
+4 -2
View File
@@ -20,8 +20,10 @@ const Authenticated = ({ children }: Props) => {
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
if (i18n.language !== language) {
i18n.changeLanguage(language);
if (language && i18n.language !== language) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
i18n.changeLanguage(language.replace("_", "-"));
}
}, [i18n, language]);
+6 -2
View File
@@ -3,13 +3,17 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import User from "models/User";
import placeholder from "./placeholder.png";
type Props = {
type Props = {|
src: string,
size: number,
icon?: React.Node,
};
user?: User,
onClick?: () => void,
className?: string,
|};
@observer
class Avatar extends React.Component<Props> {
+34 -36
View File
@@ -4,7 +4,6 @@ import {
ArchiveIcon,
EditIcon,
GoToIcon,
MoreIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
@@ -14,20 +13,18 @@ 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 = {
type Props = {|
document: Document,
collections: CollectionsStore,
children?: React.Node,
onlyText: boolean,
};
|};
function Icon({ document }) {
const { t } = useTranslation();
@@ -35,11 +32,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 +44,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 +56,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 +68,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 />
</>
);
@@ -83,14 +80,16 @@ function Icon({ document }) {
return null;
}
const Breadcrumb = ({ document, onlyText }: Props) => {
const Breadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
if (!collections.isLoaded) {
return <Wrapper />;
}
let collection = collections.get(document.collectionId);
if (!collection) {
if (!document.deletedAt) return <div />;
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
@@ -135,7 +134,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
</CollectionName>
{isNestedDocument && (
<>
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} />
<Slash /> <BreadcrumbMenu path={menuPath} />
</>
)}
{lastPath && (
@@ -146,10 +145,16 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
</Crumb>
</>
)}
{children}
</Wrapper>
);
};
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Wrapper = styled(Flex)`
display: none;
@@ -170,22 +175,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 +190,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>
);
}
@@ -3,11 +3,15 @@ import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "shared/styles/animations";
type Props = {
type Props = {|
count: number,
};
|};
const Bubble = ({ count }: Props) => {
if (!count) {
return null;
}
return <Count>{count}</Count>;
};
+42 -18
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,14 +102,14 @@ 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;"};
`;
export type Props = {
type?: string,
export type Props = {|
type?: "button" | "submit",
value?: string,
icon?: React.Node,
iconColor?: string,
@@ -107,9 +117,22 @@ export type Props = {
children?: React.Node,
innerRef?: React.ElementRef<any>,
disclosure?: boolean,
neutral?: boolean,
danger?: boolean,
primary?: boolean,
disabled?: boolean,
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any>,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
};
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
|};
function Button({
type = "text",
@@ -118,13 +141,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>}
+23
View File
@@ -0,0 +1,23 @@
// @flow
import * as React from "react";
import styled from "styled-components";
type Props = {
onClick: (ev: SyntheticEvent<>) => void,
children: React.Node,
};
export default function ButtonLink(props: Props) {
return <Button {...props} />;
}
const Button = styled.button`
margin: 0;
padding: 0;
border: 0;
color: ${(props) => props.theme.link};
line-height: inherit;
background: none;
text-decoration: none;
cursor: pointer;
`;
+6 -3
View File
@@ -1,18 +1,21 @@
// @flow
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import HelpText from "components/HelpText";
import VisuallyHidden from "components/VisuallyHidden";
export type Props = {
export type Props = {|
checked?: boolean,
label?: string,
labelHidden?: boolean,
className?: string,
name?: string,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
note?: string,
short?: boolean,
small?: boolean,
};
|};
const LabelText = styled.span`
font-weight: 500;
+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,50 +1,62 @@
// @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 = {
type Props = {|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
children?: React.Node,
selected?: boolean,
disabled?: boolean,
};
to?: string,
href?: string,
target?: "_blank",
as?: string | React.ComponentType<*>,
|};
const 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 +70,7 @@ const MenuItem = styled.a`
}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
@@ -66,7 +79,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 +98,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;
}
`;
@@ -156,7 +156,7 @@ const Wrapper = styled(Flex)`
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth};
min-width: ${(props) => props.theme.sidebarWidth}px;
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
@@ -165,7 +165,7 @@ const Wrapper = styled(Flex)`
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth};
min-width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
@@ -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>
+9 -4
View File
@@ -2,12 +2,17 @@
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 = {
type Props = {|
documents: Document[],
limit?: number,
};
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
export default function DocumentList({ limit, documents, ...rest }: Props) {
const items = limit ? documents.splice(0, limit) : documents;
@@ -18,7 +23,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>
);
+243
View File
@@ -0,0 +1,243 @@
// @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,
showNestedDocuments?: boolean,
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,
showNestedDocuments,
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}
showNestedDocuments={showNestedDocuments}
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);
+15 -2
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`
@@ -22,19 +23,21 @@ const Modified = styled.span`
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
type Props = {
type Props = {|
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
showNestedDocuments?: boolean,
document: Document,
children: React.Node,
to?: string,
};
|};
function DocumentMeta({
showPublished,
showCollection,
showLastViewed,
showNestedDocuments,
document,
children,
to,
@@ -122,6 +125,10 @@ function DocumentMeta({
);
};
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
return (
<Container align="center" {...rest}>
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
@@ -134,6 +141,12 @@ function DocumentMeta({
</strong>
</span>
)}
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp;&middot; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
+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;
+8 -2
View File
@@ -60,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;
@@ -87,7 +89,11 @@ class DropToImport extends React.Component<Props> {
isDragAccept,
isDragReject,
}) => (
<DropzoneContainer {...getRootProps()} {...{ isDragActive }}>
<DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
tabIndex="-1"
>
<input {...getInputProps()} />
{this.isImporting && <LoadingIndicator />}
{this.props.children}
-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";
+30 -13
View File
@@ -8,21 +8,39 @@ import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import isInternalUrl from "utils/isInternalUrl";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const EMPTY_ARRAY = [];
type Props = {
export type Props = {|
id?: string,
value?: string,
defaultValue?: string,
readOnly?: boolean,
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
};
autoFocus?: boolean,
template?: boolean,
placeholder?: string,
scrollTo?: string,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
onFocus?: (event: SyntheticEvent<>) => any,
onPublish?: (event: SyntheticEvent<>) => any,
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
onCancel?: () => any,
onChange?: (getValue: () => string) => any,
onSearchLink?: (title: string) => any,
onHoverLink?: (event: MouseEvent) => any,
onCreateLink?: (title: string) => Promise<string>,
onImageUploadStart?: () => any,
onImageUploadStop?: () => any,
|};
type PropsWithRef = Props & {
forwardedRef: React.Ref<any>,
@@ -49,7 +67,7 @@ function Editor(props: PropsWithRef) {
return;
}
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
// relative
let navigateTo = href;
@@ -171,17 +189,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 {
+4 -3
View File
@@ -1,4 +1,5 @@
// @flow
import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -36,8 +37,8 @@ class ErrorBoundary extends React.Component<Props> {
return;
}
if (window.Sentry) {
window.Sentry.captureException(error);
if (env.SENTRY_DSN) {
Sentry.captureException(error);
}
}
@@ -56,7 +57,7 @@ class ErrorBoundary extends React.Component<Props> {
render() {
if (this.error) {
const error = this.error;
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
+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;
}
`;
+1 -1
View File
@@ -8,7 +8,7 @@ import { fadeAndSlideIn } from "shared/styles/animations";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import isInternalUrl from "utils/isInternalUrl";
import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
+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 aria-label={t("Show menu")} {...props}>
<Component 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;
+15
View File
@@ -0,0 +1,15 @@
// @flow
import * as React from "react";
import { cdnPath } from "utils/urls";
type Props = {|
alt: string,
src: string,
title?: string,
width?: number,
height?: number,
|};
export default function Image({ src, alt, ...rest }: Props) {
return <img src={cdnPath(src)} alt={alt} {...rest} />;
}
+13 -4
View File
@@ -2,9 +2,9 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import Flex from "components/Flex";
import VisuallyHidden from "components/VisuallyHidden";
const RealTextarea = styled.textarea`
border: 0;
@@ -75,8 +75,8 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = {
type?: string,
export type Props = {|
type?: "text" | "email" | "checkbox" | "search",
value?: string,
label?: string,
className?: string,
@@ -85,9 +85,18 @@ export type Props = {
short?: boolean,
margin?: string | number,
icon?: React.Node,
name?: string,
minLength?: number,
maxLength?: number,
autoFocus?: boolean,
autoComplete?: boolean | string,
readOnly?: boolean,
required?: boolean,
placeholder?: string,
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
onFocus?: (ev: SyntheticEvent<>) => void,
onBlur?: (ev: SyntheticEvent<>) => void,
};
|};
@observer
class Input extends React.Component<Props> {
+2 -2
View File
@@ -8,13 +8,13 @@ import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
type Props = {
type Props = {|
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
ui: UiStore,
};
|};
@observer
class InputRich extends React.Component<Props> {
+6 -1
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 = {
@@ -16,6 +17,8 @@ type Props = {
theme: Theme,
source: string,
placeholder?: string,
label?: string,
labelHidden?: boolean,
collectionId?: string,
t: TFunction,
};
@@ -25,7 +28,7 @@ class InputSearch extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
@keydown("meta+f")
@keydown(`${meta}+f`)
focus(ev: SyntheticEvent<>) {
ev.preventDefault();
@@ -67,6 +70,8 @@ class InputSearch extends React.Component<Props> {
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
label={this.props.label}
labelHidden={this.props.labelHidden}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
margin={0}
+6 -2
View File
@@ -2,17 +2,19 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import VisuallyHidden from "components/VisuallyHidden";
import { Outline, LabelText } from "./Input";
const Select = styled.select`
border: 0;
flex: 1;
padding: 8px 12px;
padding: 4px 0;
margin: 0 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
height: 30px;
&:disabled,
&::placeholder {
@@ -34,6 +36,8 @@ export type Props = {
className?: string,
labelHidden?: boolean,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
};
@observer
+2 -2
View File
@@ -4,10 +4,10 @@ import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {
type Props = {|
label: React.Node | string,
children: React.Node,
};
|};
const Labeled = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
+14 -3
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { languages, languageOptions } from "shared/i18n";
import ButtonLink from "components/ButtonLink";
import Flex from "components/Flex";
import NoticeTip from "components/NoticeTip";
import useCurrentUser from "hooks/useCurrentUser";
@@ -68,7 +69,7 @@ export default function LanguagePrompt() {
like to change?
</Trans>
<br />
<a
<Link
onClick={() => {
auth.updateUser({
language,
@@ -77,14 +78,24 @@ export default function LanguagePrompt() {
}}
>
{t("Change Language")}
</a>{" "}
&middot; <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
</Link>{" "}
&middot;{" "}
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
</span>
</Flex>
</NoticeTip>
);
}
const Link = styled(ButtonLink)`
color: ${(props) => props.theme.almostBlack};
font-weight: 500;
&:hover {
text-decoration: underline;
}
`;
const LanguageIcon = styled(Icon)`
margin-right: 12px;
`;
+48 -12
View File
@@ -1,6 +1,7 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next";
@@ -14,14 +15,17 @@ import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Analytics from "components/Analytics";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Modal from "components/Modal";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import {
homeUrl,
searchUrl,
@@ -65,14 +69,13 @@ class Layout extends React.Component<Props> {
window.document.body.style.background = props.theme.background;
}
@keydown("meta+.")
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
}
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
if (this.props.ui.editMode) return;
this.keyboardShortcutsOpen = true;
}
@@ -80,9 +83,8 @@ 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();
ev.stopPropagation();
this.redirectTo = searchUrl();
@@ -90,7 +92,6 @@ class Layout extends React.Component<Props> {
@keydown("d")
goToDashboard() {
if (this.props.ui.editMode) return;
this.redirectTo = homeUrl();
}
@@ -98,6 +99,7 @@ class Layout extends React.Component<Props> {
const { auth, t, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
@@ -111,11 +113,19 @@ class Layout extends React.Component<Props> {
content="width=device-width, initial-scale=1.0"
/>
</Helmet>
<SkipNavLink />
<Analytics />
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
{this.props.notifications}
<MobileMenuButton
onClick={ui.toggleMobileSidebar}
icon={<MenuIcon />}
iconColor="currentColor"
neutral
/>
<Container auto>
{showSidebar && (
<Switch>
@@ -124,10 +134,17 @@ class Layout extends React.Component<Props> {
</Switch>
)}
<SkipNavContent />
<Content
auto
justify="center"
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
style={
sidebarCollapsed
? undefined
: { marginLeft: `${ui.sidebarWidth}px` }
}
>
{this.props.children}
</Content>
@@ -159,19 +176,38 @@ const Container = styled(Flex)`
min-height: 100%;
`;
const MobileMenuButton = styled(Button)`
position: fixed;
top: 12px;
left: 12px;
z-index: ${(props) => props.theme.depths.sidebar - 1};
${breakpoint("tablet")`
display: none;
`};
@media print {
display: none;
}
`;
const Content = styled(Flex)`
margin: 0;
transition: margin-left 100ms ease-out;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@media print {
margin: 0;
}
${breakpoint("mobile", "tablet")`
margin-left: 0 !important;
`}
${breakpoint("tablet")`
margin-left: ${(props) =>
props.sidebarCollapsed
? props.theme.sidebarCollapsedWidth
: props.theme.sidebarWidth};
${(props) =>
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
`};
`;
+89
View File
@@ -0,0 +1,89 @@
// @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`),
it: require(`date-fns/locale/it`),
ko: require(`date-fns/locale/ko`),
pt: require(`date-fns/locale/pt`),
zh: require(`date-fns/locale/zh_cn`),
};
let callbacks = [];
// 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;
+3 -3
View File
@@ -5,10 +5,10 @@ import { randomInteger } from "shared/random";
import { pulsate } from "shared/styles/animations";
import Flex from "components/Flex";
type Props = {
type Props = {|
header?: boolean,
height?: number,
};
|};
class Mask extends React.Component<Props> {
width: number;
@@ -23,7 +23,7 @@ class Mask extends React.Component<Props> {
}
render() {
return <Redacted width={this.width} {...this.props} />;
return <Redacted width={this.width} />;
}
}
+2 -2
View File
@@ -13,12 +13,12 @@ import Scrollable from "components/Scrollable";
ReactModal.setAppElement("#root");
type Props = {
type Props = {|
children?: React.Node,
isOpen: boolean,
title?: string,
onRequestClose: () => void,
};
|};
const GlobalStyles = createGlobalStyle`
.ReactModal__Overlay {
+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} />
);
+10 -9
View File
@@ -1,16 +1,17 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import AuthStore from "stores/AuthStore";
import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
type Props = {
type Props = {|
title: string,
favicon?: string,
auth: AuthStore,
};
|};
const PageTitle = observer(({ auth, title, favicon }: Props) => {
const PageTitle = ({ title, favicon }: Props) => {
const { auth } = useStores();
const { team } = auth;
return (
@@ -21,12 +22,12 @@ const PageTitle = observer(({ auth, title, favicon }: Props) => {
<link
rel="shortcut icon"
type="image/png"
href={favicon || "/favicon-32.png"}
href={favicon || cdnPath("/favicon-32.png")}
sizes="32x32"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Helmet>
);
});
};
export default inject("auth")(PageTitle);
export default observer(PageTitle);
+10 -4
View File
@@ -2,16 +2,22 @@
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 = {
type Props = {|
documents: Document[],
fetch: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
};
showNestedDocuments?: boolean,
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
|};
@observer
class PaginatedDocumentList extends React.Component<Props> {
@@ -26,7 +32,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} />
)}
/>
);
-66
View File
@@ -1,66 +0,0 @@
// @flow
import BoundlessPopover from "boundless-popover";
import * as React from "react";
import styled, { keyframes } from "styled-components";
const fadeIn = keyframes`
from {
opacity: 0;
}
50% {
opacity: 1;
}
`;
const StyledPopover = styled(BoundlessPopover)`
animation: ${fadeIn} 150ms ease-in-out;
display: flex;
flex-direction: column;
line-height: 0;
position: absolute;
top: 0;
left: 0;
z-index: ${(props) => props.theme.depths.popover};
svg {
height: 16px;
width: 16px;
position: absolute;
polygon:first-child {
fill: rgba(0, 0, 0, 0.075);
}
polygon {
fill: #fff;
}
}
`;
const Dialog = styled.div`
outline: none;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 8px 16px rgba(0, 0, 0, 0.1),
0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 4px;
line-height: 1.5;
padding: 16px;
margin-top: 14px;
min-width: 200px;
min-height: 150px;
`;
export const Preset = BoundlessPopover.preset;
export default function Popover(props: Object) {
return (
<StyledPopover
dialogComponent={Dialog}
closeOnOutsideScroll
closeOnOutsideFocus
closeOnEscKey
{...props}
/>
);
}
+12
View File
@@ -0,0 +1,12 @@
// @flow
import * as Sentry from "@sentry/react";
import { Route } from "react-router-dom";
import env from "env";
let Component = Route;
if (env.SENTRY_DSN) {
Component = Sentry.withSentryRouting(Route);
}
export default Component;
+54 -19
View File
@@ -1,28 +1,52 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useWindowSize from "hooks/useWindowSize";
type Props = {
type Props = {|
shadow?: boolean,
};
topShadow?: boolean,
bottomShadow?: boolean,
|};
@observer
class Scrollable extends React.Component<Props> {
@observable shadow: boolean = false;
function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
const ref = React.useRef<?HTMLDivElement>();
const [topShadowVisible, setTopShadow] = React.useState(false);
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
const { height } = useWindowSize();
handleScroll = (ev: SyntheticMouseEvent<HTMLDivElement>) => {
this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
};
const updateShadows = React.useCallback(() => {
const c = ref.current;
if (!c) return;
render() {
const { shadow, ...rest } = this.props;
const scrollTop = c.scrollTop;
const tsv = !!((shadow || topShadow) && scrollTop > 0);
if (tsv !== topShadowVisible) {
setTopShadow(tsv);
}
return (
<Wrapper onScroll={this.handleScroll} shadow={this.shadow} {...rest} />
);
}
const wrapperHeight = c.scrollHeight - c.clientHeight;
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
}
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
React.useEffect(() => {
updateShadows();
}, [height, updateShadows]);
return (
<Wrapper
ref={ref}
onScroll={updateShadows}
$topShadowVisible={topShadowVisible}
$bottomShadowVisible={bottomShadowVisible}
{...rest}
/>
);
}
const Wrapper = styled.div`
@@ -31,9 +55,20 @@ const Wrapper = styled.div`
overflow-x: hidden;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
box-shadow: ${(props) =>
props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"};
transition: all 250ms ease-in-out;
box-shadow: ${(props) => {
if (props.$topShadowVisible && props.$bottomShadowVisible) {
return "0 1px inset rgba(0,0,0,.1), 0 -1px inset rgba(0,0,0,.1)";
}
if (props.$topShadowVisible) {
return "0 1px inset rgba(0,0,0,.1)";
}
if (props.$bottomShadowVisible) {
return "0 -1px inset rgba(0,0,0,.1)";
}
return "none";
}};
transition: all 100ms ease-in-out;
`;
export default Scrollable;
export default observer(Scrollable);
+159 -161
View File
@@ -1,6 +1,5 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import {
ArchiveIcon,
HomeIcon,
@@ -10,194 +9,193 @@ import {
ShapesIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import Bubble from "./components/Bubble";
import Collections from "./components/Collections";
import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
type Props = {
auth: AuthStore,
documents: DocumentsStore,
policies: PoliciesStore,
t: TFunction,
};
function MainSidebar() {
const { t } = useTranslation();
const { policies, auth, documents } = useStores();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
setCreateCollectionModalOpen,
] = React.useState(false);
@observer
class MainSidebar extends React.Component<Props> {
@observable inviteModalOpen = false;
@observable createCollectionModalOpen = false;
React.useEffect(() => {
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
componentDidMount() {
this.props.documents.fetchDrafts();
this.props.documents.fetchTemplates();
}
const handleCreateCollectionModalOpen = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
setCreateCollectionModalOpen(true);
},
[]
);
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
const handleCreateCollectionModalClose = React.useCallback(() => {
setCreateCollectionModalOpen(false);
}, []);
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
this.createCollectionModalOpen = true;
};
setInviteModalOpen(true);
}, []);
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
this.createCollectionModalOpen = false;
};
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.inviteModalOpen = true;
};
const { user, team } = auth;
if (!user || !team) return null;
handleInviteModalClose = () => {
this.inviteModalOpen = false;
};
const can = policies.abilities(team.id);
render() {
const { auth, documents, policies, t } = this.props;
const { user, team } = auth;
if (!user || !team) return null;
const can = policies.abilities(team.id);
return (
<Sidebar>
<AccountMenu
label={
<HeaderBlock
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
return (
<Sidebar>
<AccountMenu>
{(props) => (
<HeaderBlock
{...props}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
/>
)}
</AccountMenu>
<Flex auto column>
<Scrollable shadow>
<Section>
<SidebarLink
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
}
/>
<Flex auto column>
<Scrollable shadow>
<Section>
<SidebarLink
to={{
pathname: "/search",
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={documents.active ? documents.active.template : undefined}
/>
<SidebarLink
to="/drafts"
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
{t("Drafts")}
<Bubble count={documents.totalDrafts} />
</Drafts>
}
active={
documents.active
? !documents.active.publishedAt &&
!documents.active.isDeleted &&
!documents.active.isTemplate
: undefined
}
/>
</Section>
<Section>
<Collections onCreateCollection={handleCreateCollectionModalOpen} />
</Section>
</Scrollable>
<Secondary>
<Section>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={documents.active ? documents.active.isDeleted : undefined}
/>
<SidebarLink
to="/settings"
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
{can.invite && (
<SidebarLink
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
to="/settings/people"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
/>
<SidebarLink
to={{
pathname: "/search",
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
<SidebarLink
to="/drafts"
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
{t("Drafts")}
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} />
)}
</Drafts>
}
active={
documents.active
? !documents.active.publishedAt &&
!documents.active.isDeleted &&
!documents.active.isTemplate
: undefined
}
/>
</Section>
<Section>
<Collections
onCreateCollection={this.handleCreateCollectionModalOpen}
/>
</Section>
<Section>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={
documents.active ? documents.active.isDeleted : undefined
}
/>
{can.invite && (
<SidebarLink
to="/settings/people"
onClick={this.handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={t("Invite people…")}
/>
)}
</Section>
</Scrollable>
</Flex>
<Modal
title={t("Invite people")}
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
<Invite onSubmit={this.handleInviteModalClose} />
</Modal>
<Modal
title={t("Create a collection")}
onRequestClose={this.handleCreateCollectionModalClose}
isOpen={this.createCollectionModalOpen}
>
<CollectionNew onSubmit={this.handleCreateCollectionModalClose} />
</Modal>
</Sidebar>
);
}
)}
</Section>
</Secondary>
</Flex>
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
<Modal
title={t("Create a collection")}
onRequestClose={handleCreateCollectionModalClose}
isOpen={createCollectionModalOpen}
>
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
</Modal>
</Sidebar>
);
}
const Secondary = styled.div`
overflow-x: hidden;
flex-shrink: 0;
`;
const Drafts = styled(Flex)`
height: 24px;
`;
export default withTranslation()<MainSidebar>(
inject("documents", "policies", "auth")(MainSidebar)
);
export default observer(MainSidebar);
+112 -124
View File
@@ -1,5 +1,5 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import {
DocumentIcon,
EmailIcon,
@@ -13,11 +13,9 @@ import {
ExpandedIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import type { RouterHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import Flex from "components/Flex";
import Scrollable from "components/Scrollable";
@@ -30,131 +28,123 @@ import Version from "./components/Version";
import SlackIcon from "./icons/Slack";
import ZapierIcon from "./icons/Zapier";
import env from "env";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
const isHosted = env.DEPLOYMENT === "hosted";
type Props = {
history: RouterHistory,
policies: PoliciesStore,
auth: AuthStore,
t: TFunction,
};
function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const team = useCurrentTeam();
const { policies } = useStores();
const can = policies.abilities(team.id);
@observer
class SettingsSidebar extends React.Component<Props> {
returnToDashboard = () => {
this.props.history.push("/home");
};
const returnToDashboard = React.useCallback(() => {
history.push("/home");
}, [history]);
render() {
const { policies, t, auth } = this.props;
const { team } = auth;
if (!team) return null;
return (
<Sidebar>
<HeaderBlock
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
</ReturnToApp>
}
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={returnToDashboard}
/>
const can = policies.abilities(team.id);
return (
<Sidebar>
<HeaderBlock
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
</ReturnToApp>
}
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={this.returnToDashboard}
/>
<Flex auto column>
<Scrollable shadow>
<Section>
<Header>Account</Header>
<SidebarLink
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
/>
<SidebarLink
to="/settings/notifications"
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
</Section>
<Section>
<Header>Team</Header>
{can.update && (
<SidebarLink
to="/settings/details"
icon={<TeamIcon color="currentColor" />}
label={t("Details")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon color="currentColor" />}
label={t("Security")}
/>
)}
<SidebarLink
to="/settings/people"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("People")}
/>
<SidebarLink
to="/settings/groups"
icon={<GroupIcon color="currentColor" />}
exact={false}
label={t("Groups")}
/>
<SidebarLink
to="/settings/shares"
icon={<LinkIcon color="currentColor" />}
label={t("Share Links")}
/>
{can.export && (
<SidebarLink
to="/settings/export"
icon={<DocumentIcon color="currentColor" />}
label={t("Export Data")}
/>
)}
</Section>
<Flex auto column>
<Scrollable topShadow>
<Section>
<Header>{t("Account")}</Header>
<SidebarLink
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
/>
<SidebarLink
to="/settings/notifications"
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
</Section>
<Section>
<Header>{t("Team")}</Header>
{can.update && (
<Section>
<Header>{t("Integrations")}</Header>
<SidebarLink
to="/settings/details"
icon={<TeamIcon color="currentColor" />}
label={t("Details")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon color="currentColor" />}
label={t("Security")}
/>
)}
<SidebarLink
to="/settings/people"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("People")}
/>
<SidebarLink
to="/settings/groups"
icon={<GroupIcon color="currentColor" />}
exact={false}
label={t("Groups")}
/>
<SidebarLink
to="/settings/shares"
icon={<LinkIcon color="currentColor" />}
label={t("Share Links")}
/>
{can.export && (
<SidebarLink
to="/settings/export"
icon={<DocumentIcon color="currentColor" />}
label={t("Export Data")}
/>
)}
</Section>
{can.update && (
<Section>
<Header>{t("Integrations")}</Header>
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
{isHosted && (
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
)}
</Section>
)}
{can.update && !isHosted && (
<Section>
<Header>{t("Installation")}</Header>
<Version />
</Section>
)}
</Scrollable>
</Flex>
</Sidebar>
);
}
)}
</Section>
)}
{can.update && !isHosted && (
<Section>
<Header>{t("Installation")}</Header>
<Version />
</Section>
)}
</Scrollable>
</Flex>
</Sidebar>
);
}
const BackIcon = styled(ExpandedIcon)`
@@ -166,6 +156,4 @@ const ReturnToApp = styled(Flex)`
height: 16px;
`;
export default withTranslation()<SettingsSidebar>(
inject("auth", "policies")(SettingsSidebar)
);
export default observer(SettingsSidebar);
+204 -73
View File
@@ -1,56 +1,195 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon, MenuIcon } from "outline-icons";
import * as React from "react";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
import CollapseToggle, { Button } from "./components/CollapseToggle";
import ResizeBorder from "./components/ResizeBorder";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
let firstRender = true;
let ANIMATION_MS = 250;
type Props = {
children: React.Node,
location: Location,
};
function Sidebar({ location, children }: Props) {
function Sidebar({ children }: Props) {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const width = ui.sidebarWidth;
const collapsed = ui.isEditing || ui.sidebarCollapsed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
},
[theme, offset, minWidth, maxWidth, setWidth]
);
const handleStopDrag = React.useCallback(
(event: MouseEvent) => {
setResizing(false);
if (document.activeElement) {
document.activeElement.blur();
}
if (isSmallerThanMinimum) {
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setAnimating(false);
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
setAnimating(true);
}
} else {
setWidth(width);
}
},
[ui, isSmallerThanMinimum, minWidth, width, setWidth]
);
const handleMouseDown = React.useCallback(
(event: MouseEvent) => {
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
},
[width]
);
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), ANIMATION_MS);
}
}, [isAnimating]);
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
React.useEffect(() => {
ui.setSidebarResizing(isResizing);
}, [ui, isResizing]);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
const style = React.useMemo(
() => ({
width: `${width}px`,
}),
[width]
);
const toggleStyle = React.useMemo(
() => ({
right: "auto",
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
}),
[width, theme.sidebarCollapsedWidth, collapsed]
);
const content = (
<Container
mobileSidebarVisible={ui.mobileSidebarVisible}
collapsed={ui.editMode || ui.sidebarCollapsed}
column
>
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
<Toggle
onClick={ui.toggleMobileSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
<>
<Container
style={style}
$sidebarWidth={ui.sidebarWidth}
$isCollapsing={isCollapsing}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
</Toggle>
{children}
</Container>
{children}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
$isResizing={isResizing}
/>
{ui.sidebarCollapsed && !ui.isEditing && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</Container>
{!ui.isEditing && (
<Toggle
style={toggleStyle}
onClick={ui.toggleCollapsedSidebar}
direction={ui.sidebarCollapsed ? "right" : "left"}
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
/>
)}
</>
);
// Fade in the sidebar on first render after page load
@@ -62,82 +201,74 @@ function Sidebar({ location, children }: Props) {
return content;
}
const Background = styled.a`
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
cursor: default;
z-index: ${(props) => props.theme.depths.sidebar - 1};
background: rgba(0, 0, 0, 0.5);
`;
const Container = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: 100%;
background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
${(props) => props.theme.backgroundTransition};
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
${(props) => props.theme.backgroundTransition}
${(props) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
z-index: ${(props) => props.theme.depths.sidebar};
max-width: 70%;
min-width: 280px;
${Positioner} {
display: none;
}
@media print {
display: none;
left: 0;
}
&:before,
&:after {
content: "";
background: ${(props) => props.theme.sidebarBackground};
position: absolute;
top: -50vh;
left: 0;
width: 100%;
height: 50vh;
}
&:after {
top: auto;
bottom: -50vh;
transform: none;
}
${breakpoint("tablet")`
left: ${(props) =>
props.collapsed
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
: 0};
width: ${(props) => props.theme.sidebarWidth};
margin: 0;
z-index: 3;
min-width: 0;
transform: translateX(${(props) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
&:hover,
&:focus-within {
left: 0;
transform: none;
box-shadow: ${(props) =>
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
: props.$isSmallerThanMinimum
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"};
& ${Button} {
opacity: .75;
${Positioner} {
display: block;
}
& ${Button}:hover {
${ToggleButton} {
opacity: 1;
}
}
&:not(:hover):not(:focus-within) > div {
opacity: ${(props) => (props.collapsed ? "0" : "1")};
opacity: ${(props) => (props.$collapsed ? "0" : "1")};
transition: opacity 100ms ease-in-out;
}
`};
`;
const Toggle = styled.a`
display: flex;
align-items: center;
position: fixed;
top: 0;
left: ${(props) => (props.mobileSidebarVisible ? "auto" : 0)};
right: ${(props) => (props.mobileSidebarVisible ? 0 : "auto")};
z-index: 1;
margin: 12px;
${breakpoint("tablet")`
display: none;
`};
`;
export default withRouter(observer(Sidebar));
export default observer(Sidebar);
@@ -1,59 +0,0 @@
// @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;
@@ -2,16 +2,19 @@
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,
@@ -39,11 +42,14 @@ function CollectionLink({
const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId;
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
// Droppable
// 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);
},
@@ -51,16 +57,28 @@ function CollectionLink({
return policies.abilities(collection.id).update;
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
isOver: !!monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
});
// 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}>
<div ref={drop} style={{ position: "relative" }}>
<DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLink
<SidebarLinkWithPadding
key={collection.id}
to={collection.url}
icon={
@@ -68,7 +86,7 @@ function CollectionLink({
}
iconColor={collection.color}
expanded={expanded}
menuOpen={menuOpen}
showActions={menuOpen || expanded}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
@@ -79,19 +97,30 @@ function CollectionLink({
}
exact={false}
menu={
<CollectionMenu
position="right"
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(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) => (
collection.documents.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
@@ -100,10 +129,19 @@ function CollectionLink({
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
index={index}
/>
))}
</>
);
}
const SidebarLinkWithPadding = styled(SidebarLink)`
padding-right: 60px;
`;
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
margin-right: 4px;
`;
export default observer(CollectionLink);
@@ -42,8 +42,6 @@ class Collections extends React.Component<Props> {
@keydown("n")
goToNewDocument() {
if (this.props.ui.editMode) return;
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;
+126 -58
View File
@@ -9,6 +9,7 @@ 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";
@@ -20,19 +21,21 @@ type Props = {|
canUpdate: boolean,
collection?: Collection,
activeDocument: ?Document,
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, policies } = useStores();
const { t } = useTranslation();
@@ -76,6 +79,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();
@@ -108,6 +119,7 @@ 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({
@@ -120,77 +132,131 @@ function DocumentLink({
},
});
// Droppable
const [{ isOver, canDrop }, drop] = useDrop({
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) => ({
isOver: !!monitor.isOver(),
canDrop: monitor.canDrop(),
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 (
<>
<Draggable
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
>
<div ref={drop}>
<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}
<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
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}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
</>
}
isActiveDrop={isOver && canDrop}
depth={depth}
exact={false}
menuOpen={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</DropToImport>
</div>
</Draggable>
</>
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</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}
@@ -199,6 +265,8 @@ function DocumentLink({
prefetchDocument={prefetchDocument}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</>
@@ -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;
}
}
@@ -5,33 +5,35 @@ import styled from "styled-components";
import Flex from "components/Flex";
import TeamLogo from "components/TeamLogo";
type Props = {
type Props = {|
teamName: string,
subheading: React.Node,
showDisclosure?: boolean,
onClick: (event: SyntheticEvent<>) => void,
logoUrl: string,
};
|};
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) => (
<Wrapper>
<Header justify="flex-start" align="center" ref={ref} {...rest}>
<TeamLogo
alt={`${teamName} logo`}
src={logoUrl}
width={38}
height={38}
/>
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
</Wrapper>
)
);
const StyledExpandedIcon = styled(ExpandedIcon)`
position: absolute;
@@ -44,6 +46,7 @@ const Subheading = styled.div`
font-size: 11px;
text-transform: uppercase;
font-weight: 500;
white-space: nowrap;
color: ${(props) => props.theme.sidebarText};
`;
@@ -53,16 +56,20 @@ const TeamName = styled.div`
padding-right: 24px;
font-weight: 600;
color: ${(props) => props.theme.text};
white-space: nowrap;
text-decoration: none;
font-size: 16px;
`;
const Wrapper = styled.div`
flex-shrink: 0;
overflow: hidden;
`;
const Header = styled.button`
display: flex;
align-items: center;
flex-shrink: 0;
padding: 20px 24px;
position: relative;
background: none;
line-height: inherit;
border: 0;
@@ -0,0 +1,105 @@
// @flow
// ref: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/NavLink.js
// This file is pulled almost 100% from react-router with the addition of one
// thing, automatic scroll to the active link. It's worth the copy paste because
// it avoids recalculating the link match again.
import { createLocation } from "history";
import * as React from "react";
import {
__RouterContext as RouterContext,
matchPath,
type Location,
} from "react-router";
import { Link } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
const resolveToLocation = (to, currentLocation) =>
typeof to === "function" ? to(currentLocation) : to;
const normalizeToLocation = (to, currentLocation) => {
return typeof to === "string"
? createLocation(to, null, null, currentLocation)
: to;
};
const joinClassnames = (...classnames) => {
return classnames.filter((i) => i).join(" ");
};
type Props = {|
activeClassName?: String,
activeStyle?: Object,
className?: string,
exact?: boolean,
isActive?: any,
location?: Location,
strict?: boolean,
style?: Object,
to: string,
|};
/**
* A <Link> wrapper that knows if it's "active" or not.
*/
const NavLink = ({
"aria-current": ariaCurrent = "page",
activeClassName = "active",
activeStyle,
className: classNameProp,
exact,
isActive: isActiveProp,
location: locationProp,
strict,
style: styleProp,
to,
...rest
}: Props) => {
const linkRef = React.useRef();
const context = React.useContext(RouterContext);
const currentLocation = locationProp || context.location;
const toLocation = normalizeToLocation(
resolveToLocation(to, currentLocation),
currentLocation
);
const { pathname: path } = toLocation;
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
const match = escapedPath
? matchPath(currentLocation.pathname, {
path: escapedPath,
exact,
strict,
})
: null;
const isActive = !!(isActiveProp
? isActiveProp(match, currentLocation)
: match);
const className = isActive
? joinClassnames(classNameProp, activeClassName)
: classNameProp;
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
React.useEffect(() => {
if (isActive && linkRef.current) {
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "instant",
});
}
}, [linkRef, isActive]);
const props = {
"aria-current": (isActive && ariaCurrent) || null,
className,
style,
to: toLocation,
...rest,
};
return <Link ref={linkRef} {...props} />;
};
export default NavLink;
@@ -0,0 +1,13 @@
// @flow
import styled from "styled-components";
const ResizeBorder = styled.div`
position: absolute;
top: 0;
bottom: 0;
right: -6px;
width: 12px;
cursor: ew-resize;
`;
export default ResizeBorder;
+3 -1
View File
@@ -5,7 +5,9 @@ import Flex from "components/Flex";
const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 24px 8px;
margin: 20px 8px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
flex-shrink: 0;
`;
export default Section;
@@ -1,7 +1,10 @@
// @flow
import * as React from "react";
import { withRouter, NavLink } from "react-router-dom";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "components/EventBoundary";
import NavLink from "./NavLink";
import { type Theme } from "types";
type Props = {
@@ -10,14 +13,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,
@@ -33,13 +39,14 @@ function SidebarLink({
active,
isActiveDrop,
menu,
menuOpen,
showActions,
theme,
exact,
href,
innerRef,
depth,
...rest
history,
match,
className,
}: Props) {
const style = React.useMemo(() => {
return {
@@ -48,16 +55,20 @@ function SidebarLink({
}, [depth]);
const activeStyle = {
color: theme.text,
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
};
const activeDropStyle = {
fontWeight: 600,
};
return (
<StyledNavLink
<Link
$isActiveDrop={isActiveDrop}
activeStyle={isActiveDrop ? undefined : activeStyle}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={onMouseEnter}
@@ -65,12 +76,12 @@ function SidebarLink({
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
ref={innerRef}
className={className}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
</StyledNavLink>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</Link>
);
}
@@ -79,42 +90,49 @@ const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
`;
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;
}
}
`;
const StyledNavLink = styled(NavLink)`
const Link = styled(NavLink)`
display: flex;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 16px;
padding: 6px 16px;
border-radius: 4px;
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 {
@@ -127,11 +145,20 @@ const StyledNavLink = styled(NavLink)`
background: ${(props) => props.theme.black05};
}
&:hover {
> ${Action} {
display: inline;
&:hover,
&:active {
> ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
}
${breakpoint("tablet")`
padding: 4px 16px;
`}
`;
const Label = styled.div`
@@ -0,0 +1,75 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
direction: "left" | "right",
style?: Object,
onClick?: () => any,
};
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
({ direction = "left", onClick, style }: Props, ref) => {
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
</ToggleButton>
</Positioner>
);
}
);
export const ToggleButton = styled.button`
opacity: 0;
background: none;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%)
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
position: absolute;
top: 50vh;
padding: 8px;
border: 0;
pointer-events: none;
color: ${(props) => props.theme.divider};
&:active {
color: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: pointer;
`}
`;
export const Positioner = styled.div`
z-index: 2;
position: absolute;
top: 0;
bottom: 0;
right: -30px;
width: 30px;
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
opacity: 1;
}
`;
export default Toggle;
+8
View File
@@ -0,0 +1,8 @@
// @flow
import * as React from "react";
export const id = "skip-nav";
export default function SkipNavContent() {
return <div id={id} />;
}
+34
View File
@@ -0,0 +1,34 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { id } from "components/SkipNavContent";
export default function SkipNavLink() {
return <Anchor href={`#${id}`}>Skip navigation</Anchor>;
}
const Anchor = styled.a`
border: 0;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
position: absolute;
z-index: 1;
&:focus {
padding: 1rem;
position: fixed;
top: 12px;
left: 12px;
background: ${(props) => props.theme.background};
color: ${(props) => props.theme.text};
outline-color: ${(props) => props.theme.primary};
z-index: ${(props) => props.theme.depths.popover};
width: auto;
height: auto;
clip: auto;
}
`;
+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;
});
+66
View File
@@ -0,0 +1,66 @@
// @flow
import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import NudeButton from "./NudeButton";
type Props = {|
document: Document,
size?: number,
|};
function Star({ size, document, ...rest }: Props) {
const { t } = useTranslation();
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}
aria-label={document.isStarred ? t("Unstar") : t("Star")}
{...rest}
>
{document.isStarred ? (
<AnimatedStar size={size} color="currentColor" />
) : (
<AnimatedStar size={size} color="currentColor" as={UnstarredIcon} />
)}
</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;
+21 -5
View File
@@ -3,24 +3,33 @@ import * as React from "react";
import styled from "styled-components";
import { LabelText } from "components/Input";
type Props = {
type Props = {|
width?: number,
height?: number,
label?: string,
checked?: boolean,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
id?: string,
};
|};
function Switch({ width = 38, height = 20, label, ...props }: Props) {
function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
const component = (
<Wrapper width={width} height={height}>
<HiddenInput type="checkbox" width={width} height={height} {...props} />
<HiddenInput
type="checkbox"
width={width}
height={height}
disabled={disabled}
{...props}
/>
<Slider width={width} height={height} />
</Wrapper>
);
if (label) {
return (
<Label htmlFor={props.id}>
<Label disabled={disabled} htmlFor={props.id}>
{component}
<LabelText>{label}</LabelText>
</Label>
@@ -33,6 +42,8 @@ function Switch({ width = 38, height = 20, label, ...props }: Props) {
const Label = styled.label`
display: flex;
align-items: center;
${(props) => (props.disabled ? `opacity: 0.75;` : "")}
`;
const Wrapper = styled.label`
@@ -76,6 +87,11 @@ const HiddenInput = styled.input`
height: 0;
visibility: hidden;
&:disabled + ${Slider} {
opacity: 0.75;
cursor: default;
}
&:checked + ${Slider} {
background-color: ${(props) => props.theme.primary};
}
+2 -2
View File
@@ -10,8 +10,8 @@ type Props = {
const StyledNavLink = styled(NavLink)`
position: relative;
display: inline-block;
display: inline-flex;
align-items: center;
font-weight: 500;
font-size: 14px;
color: ${(props) => props.theme.textTertiary};
+1
View File
@@ -8,6 +8,7 @@ const Tabs = styled.nav`
margin-bottom: 12px;
overflow-y: auto;
white-space: nowrap;
transition: opacity 250ms ease-out;
`;
export const Separator = styled.span`
+5 -2
View File
@@ -2,12 +2,15 @@
import styled from "styled-components";
const TeamLogo = styled.img`
width: ${(props) => props.size || "auto"};
height: ${(props) => props.size || "38px"};
width: ${(props) =>
props.width ? `${props.width}px` : props.size || "auto"};
height: ${(props) =>
props.height ? `${props.height}px` : props.size || "38px"};
border-radius: 4px;
background: ${(props) => props.theme.background};
border: 1px solid ${(props) => props.theme.divider};
overflow: hidden;
flex-shrink: 0;
`;
export default TeamLogo;
+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;
+2 -2
View File
@@ -3,14 +3,14 @@ import Tippy from "@tippy.js/react";
import * as React from "react";
import styled from "styled-components";
type Props = {
type Props = {|
tooltip: React.Node,
shortcut?: React.Node,
placement?: "top" | "bottom" | "left" | "right",
children: React.Node,
delay?: number,
className?: string,
};
|};
class Tooltip extends React.Component<Props> {
render() {
-13
View File
@@ -1,13 +0,0 @@
// @flow
import styled from "styled-components";
const VisuallyHidden = styled("span")`
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
clip: rect(1px, 1px, 1px, 1px);
`;
export default VisuallyHidden;
+2 -1
View File
@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
@@ -20,7 +21,7 @@ export default class GoogleDocs extends React.Component<Props> {
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img
<Image
src="/images/google-docs.png"
alt="Google Docs Icon"
width={16}
+39
View File
@@ -0,0 +1,39 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https://docs.google.com/drawings/d/(.*)/(edit|preview)(.*)$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDrawings extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<Image
src="/images/google-drawings.png"
alt="Google Drawings"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href.replace("/preview", "/edit")}
title="Google Drawings"
border
/>
);
}
}
+29
View File
@@ -0,0 +1,29 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDrawings from "./GoogleDrawings";
describe("GoogleDrawings", () => {
const match = GoogleDrawings.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit?usp=sharing".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://docs.google.com/drawings/d/e/2PACX-1vRtzIzEWN6svSrIYZq-kq2XZEN6WaOFXHbPKRLXNOFRlxLIdJg0Vo6RfretGqs9SzD-fUazLeS594Kw/pub?w=960&h=720".match(
match
)
).toBe(null);
expect("https://docs.google.com/drawings".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});
+38
View File
@@ -0,0 +1,38 @@
// @flow
import * as React from "react";
import Image from "components/Image";
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={
<Image
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);
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
@@ -20,7 +21,7 @@ export default class GoogleSlides extends React.Component<Props> {
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img
<Image
src="/images/google-sheets.png"
alt="Google Sheets Icon"
width={16}
+2 -1
View File
@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
@@ -22,7 +23,7 @@ export default class GoogleSlides extends React.Component<Props> {
.replace("/edit", "/preview")
.replace("/pub", "/embed")}
icon={
<img
<Image
src="/images/google-slides.png"
alt="Google Slides Icon"
width={16}
+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);
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^\/]*)\/videos\/|)(\d+)(?:|\/\?)/;
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|)(\d+)(?:|\/\?)/;
type Props = {|
attrs: {|
+11 -11
View File
@@ -6,6 +6,17 @@ import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
// https://www.styled-components.com/docs/basics#passed-props
const Iframe = (props) => <iframe title="Embed" {...props} />;
const StyledIframe = styled(Iframe)`
border: 1px solid;
border-color: ${(props) => props.theme.embedBorder};
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
display: block;
`;
type Props = {
src?: string,
border?: boolean,
@@ -129,17 +140,6 @@ const Bar = styled(Flex)`
user-select: none;
`;
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
// https://www.styled-components.com/docs/basics#passed-props
const Iframe = (props) => <iframe {...props} />;
const StyledIframe = styled(Iframe)`
border: 1px solid;
border-color: ${(props) => props.theme.embedBorder};
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
display: block;
`;
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
<Frame {...props} forwardedRef={ref} />
));
+18 -1
View File
@@ -1,6 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Image from "components/Image";
import Abstract from "./Abstract";
import Airtable from "./Airtable";
import ClickUp from "./ClickUp";
@@ -9,6 +10,8 @@ import Figma from "./Figma";
import Framer from "./Framer";
import Gist from "./Gist";
import GoogleDocs from "./GoogleDocs";
import GoogleDrawings from "./GoogleDrawings";
import GoogleDrive from "./GoogleDrive";
import GoogleSheets from "./GoogleSheets";
import GoogleSlides from "./GoogleSlides";
import InVision from "./InVision";
@@ -37,7 +40,7 @@ function matcher(Component) {
};
}
const Img = styled.img`
const Img = styled(Image)`
margin: 4px;
width: 18px;
height: 18px;
@@ -93,6 +96,20 @@ export default [
component: Gist,
matcher: matcher(Gist),
},
{
title: "Google Drawings",
keywords: "drawings",
icon: () => <Img src="/images/google-drawings.png" />,
component: GoogleDrawings,
matcher: matcher(GoogleDrawings),
},
{
title: "Google Drive",
keywords: "drive",
icon: () => <Img src="/images/google-drive.png" />,
component: GoogleDrive,
matcher: matcher(GoogleDrive),
},
{
title: "Google Docs",
icon: () => <Img src="/images/google-docs.png" />,
+9
View File
@@ -0,0 +1,9 @@
// @flow
import invariant from "invariant";
import useStores from "./useStores";
export default function useCurrentTeam() {
const { auth } = useStores();
invariant(auth.team, "team required");
return auth.team;
}
+12
View File
@@ -0,0 +1,12 @@
// @flow
import useStores from "./useStores";
export default function useUserLocale() {
const { auth } = useStores();
if (!auth.user || !auth.user.language) {
return undefined;
}
return auth.user.language.split("_")[0];
}
+31
View File
@@ -0,0 +1,31 @@
// @flow
import { debounce } from "lodash";
import * as React from "react";
export default function useWindowSize() {
const [windowSize, setWindowSize] = React.useState({
width: undefined,
height: undefined,
});
React.useEffect(() => {
// Handler to call on window resize
const handleResize = debounce(() => {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}, 100);
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, []);
return windowSize;
}
+15 -9
View File
@@ -1,12 +1,12 @@
// @flow
import "mobx-react-lite/batchingForReactDom";
import "focus-visible";
import { createBrowserHistory } from "history";
import { Provider } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { render } from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import { Router } from "react-router-dom";
import { initI18n } from "shared/i18n";
import stores from "stores";
import ErrorBoundary from "components/ErrorBoundary";
@@ -15,18 +15,24 @@ import Theme from "components/Theme";
import Toasts from "components/Toasts";
import Routes from "./routes";
import env from "env";
import { initSentry } from "utils/sentry";
initI18n();
const element = document.getElementById("root");
const history = createBrowserHistory();
if (env.SENTRY_DSN) {
initSentry(history);
}
if (element) {
render(
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<Provider {...stores}>
<Theme>
<ErrorBoundary>
<DndProvider backend={HTML5Backend}>
<Router>
<Router history={history}>
<>
<ScrollToTop>
<Routes />
@@ -35,9 +41,9 @@ if (element) {
</>
</Router>
</DndProvider>
</Theme>
</Provider>
</ErrorBoundary>,
</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 aria-label={t("Show path to document")} {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}>
<Template
{...menu}
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</ContextMenu>
</>
);
}

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