Compare commits

...

202 Commits

Author SHA1 Message Date
Saumya Pandey 8a1e2eb751 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-10-08 09:29:29 +05:30
Alexander Krantz 09a409b494 feat: add changing appearance for guests (#2632)
* Allow changing appearance when guest

* Apply suggestions from code review

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-10-07 18:43:41 -07:00
Saumya Pandey 584377e7de check for collaborativeEditing 2021-10-07 23:14:42 +05:30
Saumya Pandey b90ed11c5b Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-10-07 23:13:36 +05:30
Tom Moor ccd947c6e8 fix: Positioning of input select items when seleted item does not fit in available area
fix: Scroll selected item in input select
2021-10-06 23:31:35 -07:00
Tom Moor 4e05728218 fix: InputSelect disabled state 2021-10-06 22:36:45 -07:00
Saumya Pandey 40e09dd829 fix: Implement custom Select Input (#2571) 2021-10-06 21:48:43 -07:00
Tom Moor 99381d10ff translations 2021-10-06 21:17:17 -07:00
Translate-O-Tron 36c73051b4 New Crowdin updates (#2596) 2021-10-06 21:09:29 -07:00
Saumya Pandey 81718c8ee1 fix: Delete collection exports (#2595) 2021-10-06 21:08:45 -07:00
Tom Moor be905a6993 feat: Add idle detection and disconnect collaboration socket (#2629) 2021-10-06 17:37:21 -07:00
Tom Moor b39d4aade7 Bump editor, minor emoji trigger fixes and adds Perl language support 2021-10-06 08:38:43 -07:00
Tom Moor c5fb5f875f flow 2021-10-04 22:08:16 -07:00
Tom Moor 552755dace feat: Add admin UI for enabling collab editing 2021-10-04 22:00:47 -07:00
Tom Moor e61c71766f Add guard against overwriting text when collaborative editing enabled 2021-10-04 19:20:48 -07:00
Tom Moor df5dc2f691 fix: Improve graceful shutdown 2021-10-04 18:20:42 -07:00
Tom Moor 28097835d0 chore: Remove debounced search (#2625)
* Remove debounced search

* fix hover color on filter options
2021-10-04 08:04:56 -07:00
Tom Moor 3de51c1a67 Bump editor, closes #2620, #2619 2021-10-02 22:21:26 -07:00
Tom Moor 223a47af95 fix: Improve error when email field not returned from OIDC 2021-10-02 22:42:41 -04:00
Tom Moor 7c8675ce17 fix: Creating API token reloads app
fix: API keys unselectable in list
closes #2604
2021-10-02 22:39:37 -04:00
Tom Moor 157c3ce80f fix: Missing cascade on integration -> authentication relationship 2021-10-02 22:22:08 -04:00
Saumya Pandey 0ed7286fc6 fix: Move request helper function (#2594)
* Move request method to passport utils

* Use request method in OIDC provider
2021-09-29 07:20:05 -07:00
Saumya Pandey 7fc450729f Use less than or equal 2021-09-28 08:50:36 +05:30
Saumya Pandey 193b027a52 Even better 2021-09-28 08:49:48 +05:30
Tom Moor 78464f315c fix: Awareness loop in collaborative editing 2021-09-27 18:44:28 -04:00
Saumya Pandey 26466a7342 Restructure ternary operator 2021-09-28 01:47:18 +05:30
Saumya Pandey f4f3588039 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-09-28 01:15:40 +05:30
Tom Moor 79790de9b0 fix: Editor toolbar below fixed header 2021-09-27 10:40:44 -07:00
Tom Moor 252459f1cf fix: Loading flicker in collab editor when no local cache 2021-09-27 10:27:02 -07:00
Tom Moor 20a72481dc Disable embed toggling + collaborative editing 2021-09-26 21:05:32 -07:00
Tom Moor 765c7cdc27 fix: Max menu height should not affect mobile context menus 2021-09-26 17:19:00 -07:00
Tom Moor 6f136e342f fix: Context menus can extend outside of window bounds
closes #2492
2021-09-26 17:07:44 -07:00
Tom Moor 9545113d9e feat: Emoji picker in editor (#2611) 2021-09-26 15:26:32 -07:00
Tom Moor c00001086a fix: IconPicker unclosable on mobile 2021-09-26 15:26:10 -07:00
Tom Moor 95dbc8168c feat: Add 2 collection icons 2021-09-25 14:54:19 -07:00
Tom Moor 0021553518 Typescript, we need you 2021-09-25 08:55:52 -07:00
Tom Moor bcca4b91ee feat: Add 5 new collection icons 2021-09-24 19:39:31 -07:00
Tom Moor c1bd30aac8 Add user to collaboration logs 2021-09-24 19:14:00 -07:00
Tom Moor fd7dd83a4b fix: Updated database references 2021-09-23 20:09:40 -07:00
Tom Moor 26f02cdd05 fix: Table toolbars missing when cells empty 2021-09-23 19:58:16 -07:00
Tom Moor fec2baf361 fix: Memory leak in collaborative editing service 2021-09-23 17:09:15 -07:00
Tom Moor e1601fbe72 chore: Permanent team deletion (#2493) 2021-09-20 20:58:39 -07:00
dependabot[bot] a88b54d26d chore(deps): bump tmpl from 1.0.4 to 1.0.5 (#2601)
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-09-20 18:43:11 -07:00
Translate-O-Tron 88cc964d69 New Crowdin updates (#2590)
* fix: New Polish translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]
2021-09-19 19:02:01 -07:00
Saumya Pandey b8efe772fe fix: Warning when dragging document between collections with different user permissions (#2516) 2021-09-19 19:00:54 -07:00
Tom Moor b2f00d71d3 fix: Image zoom doesn't work in read-only 2021-09-19 15:26:52 -07:00
Tom Moor c2edfca6e5 fix: 'undefined' logged 2021-09-19 15:15:13 -07:00
Tom Moor 576907fdc1 merge 2021-09-19 13:44:09 -07:00
Tom Moor f56a75d2ae Merge branch 'main' into fix/2122 2021-09-19 13:43:34 -07:00
Saumya Pandey 9c3c0fe418 feat: Add Polish to languageOptions (#2593) 2021-09-19 09:45:26 -07:00
Saumya Pandey 5d2ccb5821 Add animation 2021-09-19 11:37:02 +05:30
Tom Moor 313067ff7b Add additional logging for persistence failure 2021-09-18 20:09:08 -07:00
Tom Moor be64c2b206 fix: Restore load cache, fixes TOC not visible on load 2021-09-18 17:49:00 -07:00
Tom Moor d576ce1734 fix: Remote awareness not available on doc load (collab) 2021-09-17 17:36:48 -07:00
Tom Moor 0f624958bc Use new hocuspocus hooks for collaboration metrics 2021-09-17 17:35:20 -07:00
Tom Moor 162da9a3ad fix: Can't edit title in collaborative mode 2021-09-16 22:47:58 -07:00
Tom Moor d7e9ad4f13 Remove usage of internal api 2021-09-16 21:27:37 -07:00
Tom Moor bcf773a1d6 Billibilli default hidden 2021-09-16 18:49:05 -07:00
Tom Moor 97082e8cba Merge branch 'main' of github.com:outline/outline 2021-09-16 18:48:25 -07:00
Su Yang bc3f2e4876 Add bilibili Embed Service (#2550)
* feat: Add bilibili Embed Service

* chore: code format

* chore: update bilibili icon
2021-09-16 18:48:13 -07:00
Translate-O-Tron 49a9b91708 New Crowdin updates (#2566) 2021-09-16 18:45:55 -07:00
Greg Linklater 01cea549a5 feat: map preferred_username claim to user record (#2569) 2021-09-16 18:45:37 -07:00
Tom Moor a9df3f64cf fix: Headings and code should be toggleable 2021-09-16 18:42:42 -07:00
Tom Moor e6cc8f5550 fix: Include log level in development 2021-09-16 17:22:23 -07:00
Tom Moor f6c2a95a55 Bump i18next-parser for true --silent fix 2021-09-16 16:26:57 -07:00
Tom Moor 27736f66ef fix: Various fixes for collaborative editing beta (#2586) 2021-09-15 23:27:22 -07:00
Tom Moor cde2909296 fix: Missing translation tag 2021-09-14 20:15:37 -07:00
Tom Moor 1f6e1a71f9 fix: List reverting to '0' indexing 2021-09-14 18:34:34 -07:00
Tom Moor 15ef8f7dff chore: Upgrade i18next related deps 2021-09-14 18:15:16 -07:00
Tom Moor 83a61b87ed feat: Normalized server logging (#2567)
* feat: Normalize logging

* Remove scattered console.error + Sentry.captureException

* Remove mention of debug

* cleanup dev output

* Edge cases, docs

* Refactor: Move logger, metrics, sentry under 'logging' folder.
Trying to reduce the amount of things under generic 'utils'

* cleanup, last few console calls
2021-09-14 18:04:35 -07:00
Tom Moor 6c605cf720 fix: Forward to incorrect collection url on first signin (#2565)
closes #2560
2021-09-13 21:35:52 -07:00
Tom Moor fb335887cb preventBodyScrollhideOnEsc 2021-09-13 21:00:28 -07:00
Translate-O-Tron 88e7d4c539 New Crowdin updates (#2449) 2021-09-13 20:09:52 -07:00
Tom Moor 400e32da70 fix: Various fixes for collaborative editing beta (#2561)
* fix: Remove Saving… message when collab enabled

* chore: Add tracing extension to collaboration server

* fix: Incorrect debounce behavior due to missing timestamps on events, fixes abundence of notifications when editing in realtime collab mode

* fix: Reload document prompt when collab editing
2021-09-13 17:36:26 -07:00
Tom Moor a699dea286 fix: Cleanup forking model (#2559)
* fix: Cleanup forking model
2021-09-12 21:45:52 -07:00
Tom Moor 2aca760ee0 fix: Double document highlight in sidebar (#2551)
* fix: Single highlighted doc when starred
closes #2544

* fix: Collection expand/collapse as navigating starred docs
2021-09-11 15:54:05 -07:00
Tom Moor f1c9c6fdf9 Update LICENSE 2021-09-11 09:48:19 -07:00
Tom Moor 801f6681ba Collaborative editing (#1660) 2021-09-10 22:46:57 -07:00
Tom Moor 0a998789a3 chore: Support Redis v6 on Heroku 2021-09-10 21:05:06 -07:00
Tom Moor 92016bbd06 fix: List behavior when ordered list starts at number other than 1
fix: Image improvements
fix: Image upload race condition
2021-09-10 19:06:54 -07:00
Tom Moor 231ab2da03 fix: Add recording of job errors, remove from queues on failure, centralize options 2021-09-09 22:38:34 -07:00
Tom Moor bd880ee984 chore: Add basic logging of metrics to event queue (#2545)
* chore: Add basic logging of metrics to event queue
closes #2524

* Better naming for multiple queue types

* Add stalled event
2021-09-09 21:55:45 -07:00
Tom Moor 995c6f90b7 fix: Mount _health route before catch-alls
closes #2536
2021-09-09 21:08:34 -07:00
Tom Moor 8ac853bb8b fix: Printing from doc menu is blank in Firefox
closes #2543
2021-09-09 20:41:56 -07:00
Tom Moor 2f5cf90cb7 0.59.0 2021-09-07 22:10:29 -07:00
Tom Moor c709e54738 fix: Init dd trace sooner, closes #2528 2021-09-02 23:22:31 -07:00
Tom Moor 47953b3354 Yarn.lock 2021-09-02 23:17:41 -07:00
Tom Moor d96099b5b8 Move OIDC provider to routes directory 2021-09-02 19:55:06 -07:00
Greg Linklater 4b2bf28531 feat: Generic OAuth2 Authentication (#2388)
* chore: additional dependency

* feat: OAuth2 authentication provider

* docs: add env vars

* chore: lock file

* feat: add malformed user info error and notice

* feat: configurable scopes

* fix: explicitly enable state and disable pkce

* chore: remove externally supplied username from account provisioner use

* chore: remove upstream error

* chore: add explicit import for fetch

* chore: remove unused env var from sample

* docs: openid connect claims

* fix: forward fetch errors

* feat: configurable team claim name

* docs: move OIDC env vars together

* refactor: change provider name

* refactor: rename error to match provider

* fix: resolve claim using lodash.get

* refactor: remove OIDC_TEAM_CLAIM and hard code team name
2021-09-02 19:50:17 -07:00
Tom Moor a3df9e868f fix: Server error when loading documents.info with shareId and user token and child documents shared
closes #2527
2021-09-01 23:38:43 -07:00
Tom Moor 476b5e03f9 perf: Move exports to worker service (#2514)
* first pass

* fixes

* fix: Move export related emails to queue

* i18n
2021-08-31 17:41:57 -07:00
Tom Moor 23a6459ae8 fix: Make GoogleDrive embed links more lenient
closes #2405
2021-08-30 22:43:13 -07:00
Saumya Pandey 2905306c28 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-30 15:39:57 +05:30
Saumya Pandey 88bc1aae89 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-30 15:37:20 +05:30
Saumya Pandey cd4f76270c Create useListDocumentPath hook 2021-08-30 15:31:39 +05:30
Saumya Pandey 4929fbaccb fix: Move "public document sharing" to "Permissions" (#2496)
* Convert to functional component

* Move public sharing to permissions

* Add collections.permission_changed event

* Account for null

* Update server/events.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Add collections.permission_changed event

* Remove name

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-30 11:43:42 +05:30
Tom Moor 08a8fea69a chore: Add /_health endpoint to all services
closes #2506
2021-08-29 19:44:06 -07:00
Tom Moor 2024c6e64f chore: Graceful server shutdown, closes #2507 2021-08-29 14:48:12 -07:00
Tom Moor 3dfd336f59 chore: Move all routes under routes directory (#2513)
closes #2504
2021-08-29 13:25:06 -07:00
Tom Moor 9a875920ac chore: Remove 'attachments' option from mailer 2021-08-29 12:35:55 -07:00
Saumya Pandey 90d0309b33 Create DocumentPathList to rows computation 2021-08-29 16:27:47 +05:30
Saumya Pandey 30a98df712 Pass selected value as a prop 2021-08-29 15:07:27 +05:30
Saumya Pandey f389ac6414 fix: Improvements in share feat (#2502)
* Make request only when popover is visible

* Update policies required for shares.create shares.update

* Create withCollection scope

* Remove team share check from shares.create

* Update tests
2021-08-29 10:44:09 +05:30
Saumya Pandey e4b7aa6761 fix: Add ability to choose user permission level when inviting (#2473)
* Select user role while sending invite

* Add tests to check for role

* Update app/scenes/Invite.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Use select

* Use inviteUser policy

* Remove unnecessary code

* Normalize rank/role
Fix text sizing of select input, fix alignment on users invite form

* Move component to root

* cleanup

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-29 03:05:37 +05:30
Saumya Pandey 00ba65f3ef fix: Refactor collection exports to not send email attachment (#2460)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-29 02:57:07 +05:30
Saumya Pandey c40caea4ab Minor tweaks 2021-08-28 15:31:03 +05:30
Tom Moor 28aef82af9 chore: Refactoring event processors and service architecture (#2495) 2021-08-27 21:42:13 -07:00
Saumya Pandey 86f008293a fix: Return memberships of collections (#2501) 2021-08-27 20:03:57 -07:00
Tom Moor 835fd26a95 Squashed commit of the following:
commit ebe2fe07d1a9110a99a21772b79f189dd13b4ca8
Author: Tom Moor <tom.moor@gmail.com>
Date:   Thu Aug 26 20:18:52 2021 -0700

    fix: regex, formatting

commit 1fd17b6f8a
Author: Matheus Breguêz <matbrgz@gmail.com>
Date:   Thu Aug 26 09:37:12 2021 -0300

    fix: change image size

commit 30e9bad0f5
Merge: ef99201c cc9468e2
Author: Matheus Breguêz <matbrgz@mail.com>
Date:   Thu Aug 26 09:28:34 2021 -0300

    Merge branch 'main' into feat/google-calendar-embed

commit ef99201c9d
Author: Matheus Breguêz <matbrgz@mail.com>
Date:   Fri Jul 9 11:53:56 2021 -0300

    Update GoogleCalendar.js

commit 0e91084756
Merge: e98f94c0 ec5c47e0
Author: Matheus Breguêz <matbrgz@gmail.com>
Date:   Wed Jul 7 14:49:06 2021 -0300

    Merge remote-tracking branch 'origin/feat/google-calendar-embed' into feat/google-calendar-embed

commit e98f94c02d
Author: Matheus Breguêz <matbrgz@gmail.com>
Date:   Wed Jul 7 14:29:49 2021 -0300

    feat: Add Google Calendar Embed

commit ec5c47e0c8
Author: Matheus Breguêz <matbrgz@gmail.com>
Date:   Wed Jul 7 14:29:49 2021 -0300

    feat: Add Google Calendar Embed
2021-08-26 20:20:15 -07:00
Saumya Pandey c8e16e8de0 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-27 03:17:37 +05:30
Saumya Pandey 0558049483 Update app/components/NewDocumentButton.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-27 03:17:20 +05:30
Saumya Pandey e96e293988 Use InputSearch 2021-08-27 03:16:35 +05:30
Saumya Pandey a628735b2c Reorder star at the end 2021-08-27 03:01:39 +05:30
Saumya Pandey b785da6159 Convert to functional component 2021-08-27 02:44:24 +05:30
Saumya Pandey e5eb134ae6 Use newDocumentUrl helper 2021-08-27 01:44:51 +05:30
Saumya Pandey 8efd31f1d2 Don't show collections that can't be update by user 2021-08-27 01:42:30 +05:30
Saumya Pandey ac64725964 Add policy check when publishing draft 2021-08-27 01:00:25 +05:30
Saumya Pandey 4fbb5037c5 Authorize for collection publish 2021-08-27 00:25:12 +05:30
Saumya Pandey 78e23026e6 Use hasCollection instead of can.share 2021-08-27 00:16:16 +05:30
Saumya Pandey 21c7d93131 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-27 00:11:31 +05:30
Saumya Pandey f76d72ef0a Update app/components/DocumentBreadcrumb.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-27 00:10:10 +05:30
Saumya Pandey 733490f536 Add return type 2021-08-27 00:01:51 +05:30
Saumya Pandey faa67a7403 Remove computedCollectionId 2021-08-27 00:01:41 +05:30
Saumya Pandey 900ee7ada0 Update app/menus/DocumentMenu.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:50:27 +05:30
Saumya Pandey c33515686d Update app/components/DocumentMeta.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:46:51 +05:30
Saumya Pandey e619bce571 Update app/components/DocumentListItem.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:42:20 +05:30
Saumya Pandey c9f0e3a5e6 Update app/components/DocumentListItem.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:41:17 +05:30
Tom Moor cc9468e2c5 Add 4 additional collection icons, closes #2482 2021-08-25 21:44:30 -07:00
Saumya Pandey 22ba4d0f48 fix: prevent access to docs in trash from deleted private collections (#2431)
* Check for collection in deleted document

* Add tests

* Use update policy

* Set paranoid to false when fetching deleted doc

* Update policy
2021-08-26 09:35:59 +05:30
Saumya Pandey e5981d45bf Highlight the selected path with primary color 2021-08-25 20:03:38 +05:30
Saumya Pandey eb408b0e82 Rename method 2021-08-25 19:40:18 +05:30
Saumya Pandey ecdca2b8ec Extract dialog to it's own component 2021-08-25 19:25:12 +05:30
Saumya Pandey 20429847c1 Ip from request obj 2021-08-25 18:25:44 +05:30
Saumya Pandey 6d174fe6d6 Fix typo and add translation 2021-08-25 18:21:32 +05:30
Saumya Pandey 3065296a19 Move events inside documentUpdater 2021-08-25 18:19:24 +05:30
Tom Moor d335670b91 fix: Starred untitled draft has no title in sidebar
fix: Double click to edit starred document titles
2021-08-24 23:30:55 -07:00
Tom Moor cabaee2d0a Bump editor
closes #2441
closes #2459
2021-08-24 23:23:38 -07:00
Saumya Pandey e0f9f33c81 Remove unnecessary code 2021-08-25 02:32:25 +05:30
Saumya Pandey 2a732fad09 Stop share.create request 2021-08-25 01:01:11 +05:30
Saumya Pandey be04e2abef Add space 2021-08-24 23:41:31 +05:30
Saumya Pandey cec8e375bf Delete NewDocumentMenu 2021-08-24 23:37:50 +05:30
Saumya Pandey 854e4d6af7 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-24 23:37:05 +05:30
Saumya Pandey 98e6bf6504 Replace NewDocumentMenu with NewDocumentButton 2021-08-24 23:30:23 +05:30
Saumya Pandey e5bbd7db1d Update position 2021-08-24 23:21:05 +05:30
Saumya Pandey 2fe7370252 Add checkbox and publish btn 2021-08-24 18:19:23 +05:30
Saumya Pandey f6d889f759 fix: Show starred docs in sidebar (#2317)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-23 13:07:28 +05:30
Wesley a50471959b feat: Always show share button (#2469)
This is to enable the share page also for internal team members.

closes #2444
2021-08-22 23:20:29 -07:00
Tom Moor d8ad2fc1a2 fix: Theme in account menu does not update 2021-08-22 22:19:20 -07:00
Wesley 0c48227b57 Feat: add diagrams.net/draw.io embed (#2464)
* feat: Add diagrams.net/draw.io embed

* Rename Diagrams integration to include .net

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-21 11:11:47 -07:00
Tom Moor 72da0653cc Revert "feat: Add hosted domain hint when signing in through Google SSO from subdomain (#2458)" (#2467)
This reverts commit e613ec732b.
2021-08-21 11:11:01 -07:00
Tom Moor e613ec732b feat: Add hosted domain hint when signing in through Google SSO from subdomain (#2458)
* feat: Add hosted domain hint when signing in through Google SSO from subdomain

closes #2454
2021-08-20 14:03:52 -07:00
Saumya Pandey cbf6aef0eb Minor tweaks 2021-08-21 01:32:56 +05:30
Saumya Pandey aa2980a941 Add tests on drafts doc 2021-08-21 01:32:48 +05:30
Tom Moor 0be40609ed feat: Add UI to switch teams where signed in to multiple (#2457)
* feat: Add UI to switch teams where signed in to multiple

* fix: Do not display current team in switch menu

* Refactor to hook
2021-08-18 18:37:50 -07:00
Saumya Pandey ec8fde0a5f fix: Improvements in table component (#2450) 2021-08-18 03:27:23 +05:30
Saumya Pandey 2c52a8cb8b fix: Add icons to menu items (#2373)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-13 14:21:25 -07:00
Tom Moor 1db31eed41 fix: Incorrect empty state text for /created Home tab 2021-08-13 09:57:20 -07:00
Tom Moor 8ba8013c6a fix: Suppressed notification causes missing notifications for other users on the same team 2021-08-13 09:40:43 -07:00
David Herman 1521d4dbac fix: Suppress notifications for suspended users (#2448)
* fix: Supress notifications for suspended users

* spelling

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-13 09:32:19 -07:00
Saumya Pandey a1a4fd1baf fix: Redirect to collection on self-hosted (#2438) 2021-08-13 12:32:18 +05:30
Tom Moor 31f4424018 fix: Time/LocaleTime should default to relative humanized timestamps (regression) 2021-08-12 22:47:14 -07:00
Translate-O-Tron 1f5b83aaeb New Crowdin updates (#2413) 2021-08-12 15:24:40 -07:00
Tom Moor 77db0c2e95 fix: Document history event headings (#2433)
* fix: Document history events from last year but within 12 months shown as 'this year'
fix: Events older than a year have repeated headings

* lint
2021-08-12 15:24:13 -07:00
dependabot[bot] 4cbae1cf7d chore(deps): bump path-parse from 1.0.6 to 1.0.7 (#2439)
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-08-12 15:23:43 -07:00
Tom Moor e985078b80 fix: JS error scrolling overflowed templates page
closes #2445
2021-08-12 11:26:28 -07:00
Saumya Pandey 7ffe2a90a5 Make modal mobile friendly 2021-08-12 03:49:59 +05:30
Saumya Pandey bbb41a9430 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-12 03:07:31 +05:30
Saumya Pandey 3cfca47978 Use star and unstar policies 2021-08-12 03:05:24 +05:30
Saumya Pandey 35c6b64077 Update unarchive policy to not assert for collection 2021-08-12 02:55:27 +05:30
Saumya Pandey 0095830000 Update star policy 2021-08-12 02:44:01 +05:30
Saumya Pandey ab5b3e151a Don't move archive 2021-08-12 02:35:51 +05:30
Saumya Pandey 65e5030efd Fix bug in test 2021-08-12 01:20:38 +05:30
Saumya Pandey e16bfd83ef Add modal to publish flow 2021-08-11 18:21:16 +05:30
Saumya Pandey d98ec86307 Create documentUpdater command 2021-08-11 17:06:27 +05:30
Saumya Pandey 3caeb8ba19 Create choose collection modal 2021-08-11 01:16:16 +05:30
Saumya Pandey ef8be0dd0b Refactor PathToDocument 2021-08-11 01:16:01 +05:30
Saumya Pandey 44f3ef869a Use documents.update endpoint 2021-08-10 03:35:37 +05:30
Saumya Pandey c43157a2fa Quick filteroptions to choose collection 2021-08-07 01:06:29 +05:30
Saumya Pandey b7f7af2480 Use computedCollectionId 2021-08-06 23:32:14 +05:30
Saumya Pandey fd71f82ec9 Create draft without collection 2021-08-06 23:31:49 +05:30
Saumya Pandey 4e29b14426 Updates on server to support drafts with no collection 2021-08-06 23:18:53 +05:30
Saumya Pandey 09b73401de fix: Sidebar links highlighting issue when a template is deleted or archived. (#2420) 2021-08-06 23:01:25 +05:30
Saumya Pandey 42b384688d fix: Options to create a document is available when the policies of collection in the context doesn't permits the user (#2424) 2021-08-06 22:58:26 +05:30
Tom Moor 5bdee1204e fix: Copying header results in '#' copied
fix: urls in text become linked when reloading doc
fix: Allow creation of links to anchors from link toolbar
2021-08-06 09:39:03 -07:00
Tom Moor 9db72217af feat: Include more events in document history sidebar (#2334)
closes #2230
2021-08-05 15:03:55 -07:00
Tom Moor 57a2524fbd fix: /public directory missing in new docker releases (#2417)
closes #2416
2021-08-04 09:21:25 -07:00
Tom Moor bd148f4790 fix: Paste handler should default to HTML when paste source is Outline editor
related #2416
2021-08-04 09:20:51 -07:00
Tom Moor 28d32af613 perf: Remove unused database indexes according to a month of data in production (#2395) 2021-08-03 20:51:12 -07:00
Tom Moor f2f550e1d2 fix: Policies missing on documents.viewed endpoint 2021-08-03 20:02:11 -07:00
Translate-O-Tron dad21b2186 New Crowdin updates (#2400) 2021-08-03 19:32:51 -07:00
Tom Moor 5fb5f1e8b5 perf: Remove backup column migration (#2397)
* perf: Remove no-longer-used 'backup' columns

These were added as part of the move to the v2 editor over a year ago incase any text was not correctly converted. After a year of use no cases of failed conversion have occurred that required the use of this column

* Remove migration, will do in 2-step release

* perf: Remove no-longer-used 'backup' columns

These were added as part of the move to the v2 editor over a year ago incase any text was not correctly converted. After a year of use no cases of failed conversion have occurred that required the use of this column
2021-08-03 18:55:52 -07:00
Tom Moor 2d0690697c 0.58.0 2021-08-03 15:17:06 -07:00
Tom Moor 6b551749d4 chore: Remove version- prefix from docker tags 2021-08-03 14:23:14 -07:00
Jack Baron 52fc861bcf feat: Optimize Dockerfile (#2337)
* feat: optimize dockerfile
use new dockerfile syntaxes
leverage multi-stage builds
strip yarn cache from image
use stricter yarn install command
run as a non-root user

* fix: mark yarn-deduplicate as a required dep
`yarn --production` will fail on a clean install otherwise

* fix: add sequelize required files for migrations

* fix: use correct ARG syntax for multistage builds

* revert: mark yarn-deduplicate as a required dep
no longer required as of 0b3adad751
2021-08-03 13:22:41 -07:00
Tom Moor c81c9a9d2d chore: CI Automated Builds (#2409)
closes #2408
2021-08-02 23:35:13 -07:00
Tom Moor 29c742a673 fix: Settings on 'Security' tab not persisting correctly after refactor (#2407)
* fix: Settings on 'Security' tab not persisting correctly after refactor
closes #2406
2021-08-02 13:37:53 -07:00
Tom Moor dd249021e7 fix: GoogleDrive embeds stopped working with new share urls
closes #2405
2021-08-02 11:09:16 -07:00
Tom Moor 21d3b9c7e0 fix: Formatting of welcome docs :rolleyes: 2021-08-01 13:03:21 -07:00
Tom Moor 6665dfff28 Merge branch 'main' of github.com:outline/outline 2021-08-01 12:55:03 -07:00
Tom Moor cdfe3a7fc3 chore: Add new 'getting started' onboarding document (#2391)
Remove support document
Remove confusing images
Added onboarding checklist
2021-08-01 12:54:41 -07:00
Tom Moor 401c91f90b perf: Correctly parallelize count query in users.list 2021-07-30 12:20:19 -04:00
Tom Moor ed5320507d perf: Separate slow joins (#2394) 2021-07-30 08:50:02 -07:00
423 changed files with 14703 additions and 5355 deletions
+94 -2
View File
@@ -1,4 +1,12 @@
version: 2
version: 2.1
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
working_directory: ~/outline
@@ -40,4 +48,88 @@ jobs:
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
command: yarn build:webpack
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker:
version: 20.10.6
- run:
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
workflows:
version: 2
build-and-test:
jobs:
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
+39 -17
View File
@@ -1,6 +1,6 @@
# 👋 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
# 👋 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.
@@ -12,7 +12,7 @@
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to produce this.
UTILS_SECRET=generate_a_new_key
@@ -29,9 +29,13 @@ REDIS_URL=redis://localhost:6479
URL=http://localhost:3000
PORT=3000
# ALPHA See [documentation](docs/SERVICES.md) on running the alpha version of
# the collaboration server.
COLLABORATION_URL=
# 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
# 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:
@@ -69,24 +73,42 @@ SLACK_SECRET=get_the_secret_of_above_key
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
AZURE_CLIENT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# To configure generic OIDC auth, you'll need some kind of identity provider.
# See documentation for whichever IdP you use to acquire the following info:
# Redirect URI is https://<URL>/auth/oidc.callback
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_URI=
# Specify which claims to derive user information from
# Supports any valid JSON path with the JWT payload
OIDC_USERNAME_CLAIM=preferred_username
# Display name for OIDC authentication
OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# –––––––––––––––– 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
# 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
# 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
@@ -102,15 +124,15 @@ WEB_CONCURRENCY=1
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
# 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,http,server,services
# You can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative
DEBUG=http
# 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
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# 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
#
@@ -118,13 +140,13 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
# 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=
# To support sending outgoing transactional emails such as "document updated" or
# 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=
@@ -138,6 +160,6 @@ SMTP_SECURE=true
# 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
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
+5
View File
@@ -11,6 +11,11 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/y-indexeddb/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
+40 -13
View File
@@ -1,23 +1,50 @@
FROM node:14-alpine
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM node:14-alpine AS deps-common
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:14-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
COPY package.json ./
COPY yarn.lock ./
RUN yarn --pure-lockfile
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
RUN yarn build
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
# ---
FROM node:14-alpine AS runner
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
CMD yarn start
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/public ./public
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
EXPOSE 3000
CMD ["yarn", "start"]
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.55.0
Licensed Work: Outline 0.59.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2024-04-22
Change Date: 2025-09-07
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -2,7 +2,7 @@ up:
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev
yarn dev:watch
build:
docker-compose build --pull outline
+2 -1
View File
@@ -1 +1,2 @@
web: node ./build/server/index.js
web: yarn start --services=web,websockets
worker: yarn start --services=worker
+24 -29
View File
@@ -1,5 +1,3 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
</p>
@@ -30,7 +28,6 @@ Outline requires the following dependencies:
- AWS S3 bucket or compatible API for file storage
- Slack or Google developer application for authentication
## Self-Hosted Production
### Docker
@@ -41,16 +38,20 @@ For a manual self-hosted production installation these are the recommended steps
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 db: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 db:migrate --env=production-ssl-disabled`, for example:
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn db: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
@@ -79,29 +80,27 @@ If you're running Outline by cloning this repository, run the following command
yarn run upgrade
```
## Local Development
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)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. Clone this repo
1. Register a Slack app at https://api.slack.com/apps
1. Copy the file `.env.sample` to `.env`
1. Fill out the following fields:
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. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
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. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
# Contributing
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
@@ -110,26 +109,22 @@ Before submitting a pull request please let the core team know by creating or co
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
- [Translation](docs/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.
please refer to the [architecture document](docs/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:
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
```
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
```
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
## Tests
@@ -145,7 +140,7 @@ make test
make watch
```
Once the test database is created with `make test` you may individually run
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
+17 -5
View File
@@ -35,7 +35,7 @@ const RealButton = styled.button`
border: 0;
}
&:hover {
&:hover:not(:disabled) {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
@@ -43,6 +43,10 @@ const RealButton = styled.button`
cursor: default;
pointer-events: none;
color: ${(props) => props.theme.white50};
svg {
fill: ${(props) => props.theme.white50};
}
}
${(props) =>
@@ -65,8 +69,12 @@ const RealButton = styled.button`
}
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
&:hover:not(:disabled) {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
@@ -74,6 +82,10 @@ const RealButton = styled.button`
&:disabled {
color: ${props.theme.textTertiary};
svg {
fill: ${props.theme.textTertiary};
}
}
`} ${(props) =>
props.danger &&
@@ -124,11 +136,11 @@ export type Props = {|
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any>,
as?: React.ComponentType<any> | string,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
href?: string,
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
+4 -2
View File
@@ -12,17 +12,19 @@ import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
currentUserId: string,
|};
function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
const { users, presence } = useStores();
const { document, currentUserId } = props;
const { document } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
+11 -5
View File
@@ -11,18 +11,24 @@ type Props = {
collection: Collection,
expanded?: boolean,
size?: number,
useLuminance?: boolean,
};
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
function ResolvedCollectionIcon({
collection,
expanded,
size,
useLuminance,
}: Props) {
const { ui } = useStores();
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.12
? collection.color
: "currentColor"
(ui.resolvedTheme === "dark" || useLuminance) &&
collection.color !== "currentColor" &&
getLuminance(collection.color) <= 0.12
? "currentColor"
: collection.color;
if (collection.icon && collection.icon !== "collection") {
+59
View File
@@ -0,0 +1,59 @@
// @flow
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
<Button>
<Fade>
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 32px;
margin: 24px;
${breakpoint("tablet")`
display: block;
`};
@media print {
display: none;
}
`;
const Centered = styled.div`
text-align: center;
`;
export default observer(ConnectionStatus);
+12 -5
View File
@@ -2,8 +2,9 @@
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
@@ -16,6 +17,7 @@ type Props = {|
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
icon?: React.Node,
|};
const MenuItem = ({
@@ -25,6 +27,7 @@ const MenuItem = ({
disabled,
as,
hide,
icon,
...rest
}: Props) => {
const handleClick = React.useCallback(
@@ -71,6 +74,7 @@ const MenuItem = ({
&nbsp;
</>
)}
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{children}
</MenuAnchor>
)}
@@ -84,12 +88,12 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
export const MenuAnchor = styled.a`
export const MenuAnchorCSS = css`
display: flex;
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
@@ -130,9 +134,12 @@ export const MenuAnchor = styled.a`
`};
${breakpoint("tablet")`
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
padding: 4px 12px;
font-size: 14px;
`};
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
export default MenuItem;
+25 -2
View File
@@ -9,6 +9,8 @@ import {
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Flex from "components/Flex";
import MenuIconWrapper from "components/MenuIconWrapper";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
@@ -67,7 +69,16 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
const filteredTemplates = filterTemplateItems(items);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !item.type && !!item.icon
);
return filteredTemplates.map((item, index) => {
if (iconIsPresentInAnyMenuItem && !item.type) {
item.icon = item.icon || <MenuIconWrapper />;
}
if (item.to) {
return (
<MenuItem
@@ -76,6 +87,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
{...menu}
>
{item.title}
@@ -92,6 +104,7 @@ function Template({ items, ...menu }: Props): React.Node {
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon}
{...menu}
>
{item.title}
@@ -107,6 +120,7 @@ function Template({ items, ...menu }: Props): React.Node {
disabled={item.disabled}
selected={item.selected}
key={index}
icon={item.icon}
{...menu}
>
{item.title}
@@ -120,7 +134,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
as={Submenu}
templateItems={item.items}
title={item.title}
title={<Title title={item.title} icon={item.icon} />}
{...menu}
/>
);
@@ -139,4 +153,13 @@ function Template({ items, ...menu }: Props): React.Node {
});
}
function Title({ title, icon }) {
return (
<Flex align="center">
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
+16 -6
View File
@@ -4,6 +4,7 @@ import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import useMenuHeight from "hooks/useMenuHeight";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
@@ -18,8 +19,12 @@ type Props = {|
placement?: string,
animating?: boolean,
children: React.Node,
unstable_disclosureRef?: {
current: null | React.ElementRef<"button">,
},
onOpen?: () => void,
onClose?: () => void,
hide?: () => void,
|};
export default function ContextMenu({
@@ -29,6 +34,8 @@ export default function ContextMenu({
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef();
React.useEffect(() => {
if (rest.visible && !previousVisible) {
@@ -43,6 +50,8 @@ export default function ContextMenu({
}
}, [onOpen, onClose, previousVisible, rest.visible]);
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
@@ -59,6 +68,8 @@ export default function ContextMenu({
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
style={maxHeight && topAnchor ? { maxHeight } : undefined}
>
{rest.visible || rest.animating ? children : null}
</Background>
@@ -68,14 +79,14 @@ export default function ContextMenu({
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop />
<Backdrop onClick={rest.hide} />
</Portal>
)}
</>
);
}
const Backdrop = styled.div`
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
@@ -90,7 +101,7 @@ const Backdrop = styled.div`
`};
`;
const Position = styled.div`
export const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
@@ -104,7 +115,7 @@ const Position = styled.div`
`};
`;
const Background = styled.div`
export const Background = styled.div`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
@@ -125,8 +136,7 @@ const Background = styled.div`
${breakpoint("tablet")`
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
transform-origin: ${(props) => (props.rightAnchor ? "75%" : "25%")} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
+4 -3
View File
@@ -61,8 +61,9 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { t } = useTranslation();
const category = useCategory(document);
let collection = collections.get(document.collectionId);
if (!collection) {
let collection = collections.get(document.collectionId || "");
if (!collection && document.collectionId) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
@@ -111,7 +112,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
if (onlyText === true) {
return (
<>
{collection.name}
{collection?.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
+124
View File
@@ -0,0 +1,124 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "models/Event";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PaginatedEventList from "components/PaginatedEventList";
import Scrollable from "components/Scrollable";
import useStores from "hooks/useStores";
import { documentUrl } from "utils/routeHelpers";
const EMPTY_ARRAY = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
history.push(documentUrl(document));
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event({
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
})
);
}
return eventsInDocument;
}, [eventsInDocument, document]);
return (
<Sidebar>
{document ? (
<Position column>
<Header>
<Title>{t("History")}</Title>
<Button
icon={<CloseIcon />}
onClick={onCloseHistory}
borderOnHover
neutral
/>
</Header>
<Scrollable topShadow>
<PaginatedEventList
fetch={events.fetchPage}
events={items}
options={{ documentId: document.id }}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
) : null}
</Sidebar>
);
}
const Position = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
background: ${(props) => props.theme.background};
width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default observer(DocumentHistory);
@@ -1,199 +0,0 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import PlaceholderList from "components/List/Placeholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match,
documents: DocumentsStore,
revisions: RevisionsStore,
history: RouterHistory,
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
this.selectFirstRevision();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
documentId: this.props.match.params.documentSlug,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
selectFirstRevision = () => {
if (this.revisions.length) {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return;
this.props.history.replace(
documentHistoryUrl(document, this.revisions[0].id)
);
}
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
get revisions() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return [];
return this.props.revisions.getDocumentRevisions(document.id);
}
onCloseHistory = () => {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
this.redirectTo = documentUrl(document);
};
render() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
const showLoading = (!this.isLoaded && this.isFetching) || !document;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Sidebar>
<Wrapper column>
<Header>
<Title>History</Title>
<Button
icon={<CloseIcon />}
onClick={this.onCloseHistory}
borderOnHover
neutral
/>
</Header>
{showLoading ? (
<Loading>
<PlaceholderList count={5} />
</Loading>
) : (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.revisions.map((revision, index) => (
<Revision
key={revision.id}
revision={revision}
document={document}
showMenu={index !== 0}
selected={this.props.match.params.revisionId === revision.id}
/>
))}
</ArrowKeyNavigation>
)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</Wrapper>
</Sidebar>
);
}
}
const Loading = styled.div`
margin: 0 16px;
`;
const Wrapper = styled(Flex)`
position: fixed;
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth}px;
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);
@@ -1,87 +0,0 @@
// @flow
import { format } from "date-fns";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Revision from "models/Revision";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme,
showMenu: boolean,
selected: boolean,
document: Document,
revision: Revision,
};
class RevisionListItem extends React.Component<Props> {
render() {
const { revision, document, showMenu, selected, theme } = this.props;
return (
<StyledNavLink
to={documentHistoryUrl(document, revision.id)}
activeStyle={{ background: theme.primary, color: theme.white }}
>
<Author>
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
</Time>
</Meta>
{showMenu && (
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>
);
}
}
const StyledAvatar = styled(Avatar)`
border-color: transparent;
margin-right: 4px;
`;
const StyledRevisionMenu = styled(RevisionMenu)`
position: absolute;
right: 16px;
top: 20px;
`;
const StyledNavLink = styled(NavLink)`
color: ${(props) => props.theme.text};
display: block;
padding: 8px 16px;
font-size: 15px;
position: relative;
`;
const Author = styled(Flex)`
font-weight: 500;
padding: 0;
margin: 0;
`;
const Meta = styled.p`
font-size: 14px;
opacity: 0.75;
margin: 0 0 2px;
padding: 0;
`;
export default withTheme(RevisionListItem);
-3
View File
@@ -1,3 +0,0 @@
// @flow
import DocumentHistory from "./DocumentHistory";
export default DocumentHistory;
+11 -8
View File
@@ -63,9 +63,11 @@ function DocumentListItem(props: Props, ref) {
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const canStar = policies.abilities(document.id).star;
const can = policies.abilities(currentTeam.id);
const collectionPolicy = document.collectionId
? policies.abilities(document.collectionId)
: {};
return (
<DocumentLink
@@ -88,11 +90,6 @@ function DocumentListItem(props: Props, ref) {
{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")}
@@ -105,6 +102,11 @@ function DocumentListItem(props: Props, ref) {
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
</Heading>
{!queryIsInTitle && (
@@ -126,7 +128,8 @@ function DocumentListItem(props: Props, ref) {
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument && (
can.createDocument &&
collectionPolicy.update && (
<>
<Button
as={Link}
+3 -1
View File
@@ -75,7 +75,9 @@ function DocumentMeta({
return null;
}
const collection = collections.get(document.collectionId);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
let content;
+48 -1
View File
@@ -3,12 +3,13 @@ import { lighten } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import { Extension } from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import embeds from "shared/embeds";
import { light } from "shared/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
@@ -30,6 +31,8 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
style?: Object,
extensions?: Extension[],
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
@@ -246,6 +249,50 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
`;
const EditorTooltip = ({ children, ...props }) => (
+163
View File
@@ -0,0 +1,163 @@
// @flow
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import Avatar from "components/Avatar";
import Item, { Actions } from "components/List/Item";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
event: Event,
latest?: boolean,
|};
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const opts = { userName: event.actor.name };
const isRevision = event.name === "revisions.create";
let meta, icon, to;
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon color="currentColor" size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
break;
default:
console.warn("Unhandled event: ", event.name);
}
if (!meta) {
return null;
}
return (
<ListItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={250}
format="MMMM do, h:mm a"
relative={false}
addSuffix
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
/>
);
};
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${(props) => props.theme.textSecondary};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: 50%;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.25;
transition: opacity 100ms ease-in-out;
}
&:hover {
${Actions} {
opacity: 1;
}
}
`;
export default EventListItem;
+9 -5
View File
@@ -76,11 +76,6 @@ const FilterOptions = ({
);
};
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
`;
const Note = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
@@ -90,6 +85,15 @@ const Note = styled(HelpText)`
color: ${(props) => props.theme.textTertiary};
`;
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
&:hover ${Note} {
color: ${(props) => props.theme.white50};
}
`;
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
+2 -1
View File
@@ -39,7 +39,8 @@ const Guide = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
preventBodyScroll
hideOnEsc
hide={onRequestClose}
>
{(props) => (
+2 -2
View File
@@ -36,7 +36,7 @@ function Header({ breadcrumb, title, actions }: Props) {
}, []);
return (
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
<Wrapper align="center" shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
@@ -95,7 +95,7 @@ const Wrapper = styled(Flex)`
}
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
padding: 16px 16px 0;
justify-content: "center";
`};
`;
+89 -8
View File
@@ -1,28 +1,41 @@
// @flow
import {
BookmarkedIcon,
CollectionIcon,
CoinsIcon,
AcademicCapIcon,
BeakerIcon,
BuildingBlocksIcon,
CameraIcon,
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
GlobeIcon,
InfoIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
MathIcon,
MoonIcon,
NotepadIcon,
PadlockIcon,
PaletteIcon,
PromoteIcon,
QuestionMarkIcon,
SportIcon,
SunIcon,
TargetIcon,
ToolsIcon,
VehicleIcon,
WarningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
@@ -39,6 +52,10 @@ const TwitterPicker = React.lazy(() =>
);
export const icons = {
bookmark: {
component: BookmarkedIcon,
keywords: "bookmark",
},
collection: {
component: CollectionIcon,
keywords: "collection",
@@ -47,6 +64,10 @@ export const icons = {
component: CoinsIcon,
keywords: "coins money finance sales income revenue cash",
},
camera: {
component: CameraIcon,
keywords: "photo picture",
},
academicCap: {
component: AcademicCapIcon,
keywords: "learn teach lesson guide tutorial onboarding training",
@@ -67,10 +88,26 @@ export const icons = {
component: CodeIcon,
keywords: "developer api code development engineering programming",
},
email: {
component: EmailIcon,
keywords: "email at",
},
eye: {
component: EyeIcon,
keywords: "eye view",
},
globe: {
component: GlobeIcon,
keywords: "world translate",
},
info: {
component: InfoIcon,
keywords: "info information",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
},
leaf: {
component: LeafIcon,
keywords: "leaf plant outdoors nature ecosystem climate",
@@ -79,6 +116,10 @@ export const icons = {
component: LightBulbIcon,
keywords: "lightbulb idea",
},
math: {
component: MathIcon,
keywords: "math formula",
},
moon: {
component: MoonIcon,
keywords: "night moon dark",
@@ -99,6 +140,10 @@ export const icons = {
component: EditIcon,
keywords: "copy writing post blog",
},
promote: {
component: PromoteIcon,
keywords: "marketing promotion",
},
question: {
component: QuestionMarkIcon,
keywords: "question help support faq",
@@ -107,10 +152,26 @@ export const icons = {
component: SunIcon,
keywords: "day sun weather",
},
sport: {
component: SportIcon,
keywords: "sport outdoor racket game",
},
target: {
component: TargetIcon,
keywords: "target goal sales",
},
tools: {
component: ToolsIcon,
keywords: "tool settings",
},
vehicle: {
component: VehicleIcon,
keywords: "truck car travel transport",
},
warning: {
component: WarningIcon,
keywords: "warning alert error",
},
};
const colors = [
@@ -128,18 +189,18 @@ const colors = [
type Props = {|
onOpen?: () => void,
onClose?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
function IconPicker({ onOpen, icon, color, onChange }: Props) {
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
const Component = icons[icon || "collection"].component;
return (
<Wrapper>
@@ -149,14 +210,22 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
<MenuButton {...menu}>
{(props) => (
<Button aria-label={t("Show menu")} {...props}>
<Component color={color} size={30} />
<Icon
as={icons[icon || "collection"].component}
color={color}
size={30}
/>
</Button>
)}
</MenuButton>
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
<ContextMenu
{...menu}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Choose icon")}
>
<Icons>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<MenuItem
key={name}
@@ -165,7 +234,7 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
>
{(props) => (
<IconButton style={style} {...props}>
<Component color={color} size={30} />
<Icon as={icons[name].component} color={color} size={30} />
</IconButton>
)}
</MenuItem>
@@ -187,13 +256,20 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
);
}
const Icon = styled.svg`
transition: fill 150ms ease-in-out;
`;
const Label = styled.label`
display: block;
`;
const Icons = styled.div`
padding: 15px 9px 9px 15px;
width: 276px;
padding: 16px 8px 0 16px;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Button = styled(NudeButton)`
@@ -216,6 +292,11 @@ const Loading = styled(HelpText)`
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: auto !important;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Wrapper = styled("div")`
+16 -11
View File
@@ -40,14 +40,20 @@ class InputSearchPage extends React.Component<Props> {
}
}
handleSearchInput = (ev: SyntheticInputEvent<>) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
handleKeyDown = (ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.currentTarget.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
}
if (this.props.onKeyDown) {
this.props.onKeyDown(ev);
}
};
handleFocus = () => {
@@ -59,7 +65,7 @@ class InputSearchPage extends React.Component<Props> {
};
render() {
const { t, value, onChange, onKeyDown } = this.props;
const { t, value, onChange } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
return (
@@ -67,10 +73,9 @@ class InputSearchPage extends React.Component<Props> {
ref={(ref) => (this.input = ref)}
type="search"
placeholder={placeholder}
onInput={this.handleSearchInput}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyDown={this.handleKeyDown}
icon={
<SearchIcon
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
+234 -62
View File
@@ -1,74 +1,120 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import {
Select,
SelectOption,
useSelectState,
useSelectPopover,
SelectPopover,
} from "@renderlesskit/react";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import { Outline, LabelText } from "./Input";
const Select = styled.select`
border: 0;
flex: 1;
padding: 4px 0;
margin: 0 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
height: 30px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import Button, { Inner } from "components/Button";
import { Position, Background, Backdrop } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
import useMenuHeight from "hooks/useMenuHeight";
export type Option = { label: string, value: string };
export type Props = {
value?: string,
label?: string,
nude?: boolean,
ariaLabel: string,
short?: boolean,
disabled?: boolean,
className?: string,
labelHidden?: boolean,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
onChange: (string) => Promise<void> | void,
};
@observer
class InputSelect extends React.Component<Props> {
@observable focused: boolean = false;
const getOptionFromValue = (options: Option[], value) => {
return options.find((option) => option.value === value) || {};
};
handleBlur = () => {
this.focused = false;
};
const InputSelect = (props: Props) => {
const {
value,
label,
className,
labelHidden,
options,
short,
ariaLabel,
onChange,
disabled,
nude,
} = props;
handleFocus = () => {
this.focused = true;
};
const select = useSelectState({
gutter: 0,
modal: true,
selectedValue: value,
animated: 200,
});
render() {
const {
label,
className,
labelHidden,
options,
short,
...rest
} = this.props;
const popOver = useSelectPopover({
...select,
hideOnClickOutside: true,
preventBodyScroll: true,
disabled,
});
const wrappedLabel = <LabelText>{label}</LabelText>;
const previousValue = React.useRef(value);
const contentRef = React.useRef();
const selectedRef = React.useRef();
const buttonRef = React.useRef();
const [offset, setOffset] = React.useState(0);
const minWidth = buttonRef.current?.offsetWidth || 0;
return (
const maxHeight = useMenuHeight(
select.visible,
select.unstable_disclosureRef
);
React.useEffect(() => {
if (previousValue.current === select.selectedValue) return;
previousValue.current = select.selectedValue;
async function load() {
await onChange(select.selectedValue);
}
load();
}, [onChange, select.selectedValue]);
const wrappedLabel = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(option) => option.value === select.selectedValue
);
// Ensure selected option is visible when opening the input
React.useEffect(() => {
if (!select.animating && selectedRef.current) {
scrollIntoView(selectedRef.current, {
scrollMode: "if-needed",
behavior: "instant",
block: "start",
});
}
}, [select.animating]);
React.useLayoutEffect(() => {
if (select.visible) {
const offset = Math.round(
(selectedRef.current?.getBoundingClientRect().top || 0) -
(contentRef.current?.getBoundingClientRect().top || 0)
);
setOffset(offset);
}
}, [select.visible]);
return (
<>
<Wrapper short={short}>
{label &&
(labelHidden ? (
@@ -76,18 +122,144 @@ class InputSelect extends React.Component<Props> {
) : (
wrappedLabel
))}
<Outline focused={this.focused} className={className}>
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
{options.map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</Select>
</Outline>
<Select
{...select}
disabled={disabled}
aria-label={ariaLabel}
ref={buttonRef}
>
{(props) => (
<StyledButton
neutral
disclosure
className={className}
nude={nude}
{...props}
>
{getOptionFromValue(options, select.selectedValue).label ||
`Select a ${ariaLabel}`}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popOver}>
{(props) => {
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
// offset top of select to place selected item under the cursor
if (selectedValueIndex !== -1) {
props.style.top = `-${offset + 32}px`;
}
return (
<Positioner {...props}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
style={
maxHeight && topAnchor
? { maxHeight, minWidth }
: { minWidth }
}
>
{select.visible || select.animating
? options.map((option) => (
<StyledSelectOption
{...select}
value={option.value}
key={option.value}
animating={select.animating}
ref={
select.selectedValue === option.value
? selectedRef
: undefined
}
>
{select.selectedValue !== undefined && (
<>
{select.selectedValue === option.value ? (
<CheckmarkIcon color="currentColor" />
) : (
<Spacer />
)}
&nbsp;
</>
)}
{option.label}
</StyledSelectOption>
))
: null}
</Background>
</Positioner>
);
}}
</SelectPopover>
</Wrapper>
);
{(select.visible || select.animating) && <Backdrop />}
</>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledButton = styled(Button)`
font-weight: normal;
text-transform: none;
margin-bottom: 16px;
display: block;
width: 100%;
${(props) =>
props.nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 16px;
padding-right: 8px;
justify-content: space-between;
}
}
`;
export const StyledSelectOption = styled(SelectOption)`
${MenuAnchorCSS}
${(props) =>
props.animating &&
css`
pointer-events: none;
`}
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const Positioner = styled(Position)`
&.focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
box-shadow: none;
cursor: pointer;
svg {
fill: ${(props) => props.theme.white};
}
}
}
}
`;
export default InputSelect;
+17 -3
View File
@@ -4,19 +4,33 @@ import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "./InputSelect";
export default function InputSelectPermission(
props: $Rest<Props, { options: Array<Option> }>
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
if (value === "no_access") {
value = "";
}
onChange(value);
},
[onChange]
);
return (
<InputSelect
label={t("Default access")}
options={[
{ label: t("View and edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("No access"), value: "" },
{ label: t("No access"), value: "no_access" },
]}
{...props}
ariaLabel={t("Default access")}
value={value || "no_access"}
onChange={handleChange}
{...rest}
/>
);
}
+25
View File
@@ -0,0 +1,25 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "components/InputSelect";
const InputSelectRole = (
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{ label: t("Member"), value: "member" },
{ label: t("Viewer"), value: "viewer" },
{ label: t("Admin"), value: "admin" },
]}
ariaLabel={t("Role")}
{...props}
/>
);
};
export default InputSelectRole;
+14 -7
View File
@@ -22,7 +22,6 @@ import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
import Guide from "components/Guide";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
@@ -38,6 +37,12 @@ import {
newDocumentUrl,
} from "utils/routeHelpers";
const DocumentHistory = React.lazy(() =>
import(
/* webpackChunkName: "document-history" */ "components/DocumentHistory"
)
);
type Props = {
documents: DocumentsStore,
children?: ?React.Node,
@@ -154,12 +159,14 @@ class Layout extends React.Component<Props> {
{this.props.children}
</Content>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
<React.Suspense>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</React.Suspense>
</Container>
<Guide
isOpen={this.keyboardShortcutsOpen}
+54 -23
View File
@@ -1,40 +1,64 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import Flex from "components/Flex";
import NavLink from "components/NavLink";
type Props = {
type Props = {|
image?: React.Node,
to?: string,
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
};
|};
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
ref
) => {
const theme = useTheme();
const compact = !subtitle;
return (
<Wrapper compact={compact} $border={border}>
const content = (selected) => (
<>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Content
align={compact ? "center" : undefined}
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
</Subtitle>
)}
</Content>
{actions && <Actions>{actions}</Actions>}
{actions && (
<Actions $selected={selected} gap={4}>
{actions}
</Actions>
)}
</>
);
return (
<Wrapper
ref={ref}
$border={border}
activeStyle={{ background: theme.primary }}
{...rest}
as={to ? NavLink : undefined}
to={to}
>
{to ? content : content(false)}
</Wrapper>
);
};
const Wrapper = styled.li`
const Wrapper = styled.div`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
@@ -57,28 +81,35 @@ const Image = styled(Flex)`
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2;
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
const Content = styled(Flex)`
const Content = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) => props.theme.textTertiary};
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`
export const Actions = styled(Flex)`
align-self: center;
justify-content: center;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default ListItem;
export default React.forwardRef<Props, HTMLDivElement>(ListItem);
+21 -41
View File
@@ -1,39 +1,9 @@
// @flow
import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
faIR,
fr,
es,
it,
ja,
ko,
ptBR,
pt,
zhCN,
zhTW,
ru,
} from "date-fns/locale";
import { format as formatDate, formatDistanceToNow } from "date-fns";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
const locales = {
en_US: enUS,
de_DE: de,
es_ES: es,
fa_IR: faIR,
fr_FR: fr,
it_IT: it,
ja_JP: ja,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
zh_CN: zhCN,
zh_TW: zhTW,
ru_RU: ru,
};
import { dateLocale } from "utils/i18n";
let callbacks = [];
@@ -57,6 +27,8 @@ type Props = {
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
relative?: boolean,
format?: string,
};
function LocaleTime({
@@ -64,6 +36,8 @@ function LocaleTime({
children,
dateTime,
shorten,
format,
relative,
tooltipDelay,
}: Props) {
const userLocale = useUserLocale();
@@ -82,25 +56,31 @@ function LocaleTime({
};
}, []);
let content = formatDistanceToNow(Date.parse(dateTime), {
const locale = dateLocale(userLocale);
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
addSuffix,
locale: userLocale ? locales[userLocale] : undefined,
locale,
});
if (shorten) {
content = content
relativeContent = relativeContent
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
const tooltipContent = formatDate(
Date.parse(dateTime),
format || "MMMM do, yyyy h:mm a",
{ locale }
);
const content =
children || relative !== false ? relativeContent : tooltipContent;
return (
<Tooltip
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
<Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
<time dateTime={dateTime}>{content}</time>
</Tooltip>
);
}
+11
View File
@@ -0,0 +1,11 @@
// @flow
import styled from "styled-components";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
`;
export default MenuIconWrapper;
+2 -1
View File
@@ -61,7 +61,8 @@ const Modal = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScrollhideOnEsc
preventBodyScroll
hideOnEsc
hide={onRequestClose}
>
{(props) => (
+26
View File
@@ -0,0 +1,26 @@
// @flow
import * as React from "react";
import { NavLink, Route, type Match } from "react-router-dom";
type Props = {
children?: (match: Match) => React.Node,
exact?: boolean,
to: string,
};
export default function NavLinkWithChildrenFunc({
to,
exact = false,
children,
...rest
}: Props) {
return (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink {...rest} to={to} exact={exact}>
{children ? children(match) : null}
</NavLink>
)}
</Route>
);
}
+19
View File
@@ -0,0 +1,19 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Button from "components/Button";
import { newDocumentUrl } from "utils/routeHelpers";
const NewDocumentButton = () => {
const { t } = useTranslation();
return (
<Button as={Link} to={newDocumentUrl()} icon={<PlusIcon />}>
{t("New doc")}
</Button>
);
};
export default NewDocumentButton;
+1
View File
@@ -12,6 +12,7 @@ const Button = styled.button`
padding: 0;
cursor: pointer;
user-select: none;
color: inherit;
`;
export default React.forwardRef<any, typeof Button>(
+1 -1
View File
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { cdnPath } from "../../shared/utils/urls";
import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
type Props = {|
title: string,
+53
View File
@@ -0,0 +1,53 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import PaginatedList from "components/PaginatedList";
import EventListItem from "./EventListItem";
type Props = {|
events: Event[],
document: Document,
fetch: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
|};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
events,
fetch,
options,
document,
...rest
}: Props) {
return (
<PaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
/>
);
});
const Heading = styled("h3")`
font-size: 14px;
padding: 0 12px;
`;
export default PaginatedEventList;
+48 -5
View File
@@ -2,12 +2,15 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Waypoint } from "react-waypoint";
import AuthStore from "stores/AuthStore";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { dateToHeading } from "utils/dates";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
@@ -15,7 +18,10 @@ type Props = {
heading?: React.Node,
empty?: React.Node,
items: any[],
renderItem: (any) => React.Node,
auth: AuthStore,
renderItem: (any, index: number) => React.Node,
renderHeading?: (name: React.Element<any> | string) => React.Node,
t: TFunction,
};
@observer
@@ -101,8 +107,9 @@ class PaginatedList extends React.Component<Props> {
};
render() {
const { items, heading, empty } = this.props;
const { items, heading, auth, empty, renderHeading } = this.props;
let previousHeading = "";
const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
@@ -119,7 +126,41 @@ class PaginatedList extends React.Component<Props> {
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map(this.props.renderItem)}
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
@@ -136,4 +177,6 @@ class PaginatedList extends React.Component<Props> {
}
}
export default PaginatedList;
export const Component = PaginatedList;
export default withTranslation()<PaginatedList>(inject("auth")(PaginatedList));
+1 -1
View File
@@ -4,7 +4,7 @@ import { shallow } from "enzyme";
import * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList";
import { Component as PaginatedList } from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
+41 -49
View File
@@ -13,43 +13,38 @@ type Props = {
result: DocumentPath,
document?: ?Document,
collection: ?Collection,
onSuccess?: () => void,
selected?: boolean,
setSelectedPath?: (DocumentPath) => void,
style?: Object,
ref?: (?React.ElementRef<"div">) => void,
};
@observer
class PathToDocument extends React.Component<Props> {
handleClick = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { document, result, onSuccess } = this.props;
if (!document) return;
const PathToDocument = ({
result,
collection,
document,
ref,
style,
selected,
setSelectedPath,
}: Props) => {
if (!result) return <div />;
if (result.type === "document") {
await document.move(result.collectionId, result.id);
} else {
await document.move(result.collectionId, null);
}
if (onSuccess) onSuccess();
};
render() {
const { result, collection, document, ref, style } = this.props;
const Component = document ? ResultWrapperLink : ResultWrapper;
if (!result) return <div />;
return (
<Component
ref={ref}
onClick={this.handleClick}
href=""
style={style}
role="option"
selectable
>
{collection && <CollectionIcon collection={collection} />}
return (
<ResultWrapper
ref={ref}
onClick={() => {
setSelectedPath && setSelectedPath(result);
}}
style={style}
role="option"
selectable
selected={selected}
>
<Flex>
{collection && (
<CollectionIcon collection={collection} useLuminance={selected} />
)}
&nbsp;
{result.path
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
@@ -60,10 +55,10 @@ class PathToDocument extends React.Component<Props> {
<StyledGoToIcon /> <Title>{document.title}</Title>
</DocumentTitle>
)}
</Component>
);
}
}
</Flex>
</ResultWrapper>
);
};
const DocumentTitle = styled(Flex)``;
@@ -78,23 +73,19 @@ const StyledGoToIcon = styled(GoToIcon)`
`;
const ResultWrapper = styled.div`
padding: 8px 4px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
user-select: none;
color: ${(props) => props.theme.text};
background: ${(props) => (props.selected ? props.theme.selected : "")};
color: ${(props) => (props.selected ? "white" : props.theme.text)};
cursor: default;
svg {
flex-shrink: 0;
}
`;
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
padding: 8px 4px;
border-radius: 4px;
${DocumentTitle} {
display: none;
display: ${(props) => (props.selected ? "flex" : "none")};
}
svg {
@@ -104,7 +95,8 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
&:hover,
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
background: ${(props) =>
props.selected ? "" : props.theme.listItemHoverBackground};
outline: none;
${DocumentTitle} {
@@ -113,4 +105,4 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
}
`;
export default PathToDocument;
export default observer(PathToDocument);
+37 -10
View File
@@ -6,18 +6,45 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
export default function PlaceholderDocument(props: Object) {
export default function PlaceholderDocument({
includeTitle,
delay,
}: {
includeTitle?: boolean,
delay?: number,
}) {
const content = (
<>
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</>
);
if (includeTitle === false) {
return (
<DelayedMount delay={delay}>
<Fade>
<Flex column auto>
{content}
</Flex>
</Fade>
</DelayedMount>
);
}
return (
<DelayedMount>
<DelayedMount delay={delay}>
<Wrapper>
<Flex column auto {...props}>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</Flex>
<Fade>
<Flex column auto>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
{content}
</Flex>
</Fade>
</Wrapper>
</DelayedMount>
);
+9 -10
View File
@@ -2,12 +2,11 @@
import { observer } from "mobx-react";
import {
EditIcon,
SearchIcon,
ShapesIcon,
HomeIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
ShapesIcon,
StarredIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
@@ -25,6 +24,7 @@ import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import useCurrentTeam from "hooks/useCurrentTeam";
@@ -109,12 +109,6 @@ function MainSidebar() {
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
{can.createDocument && (
<SidebarLink
to="/drafts"
@@ -135,6 +129,7 @@ function MainSidebar() {
/>
)}
</Section>
<Starred />
<Section auto>
<Collections
onCreateCollection={handleCreateCollectionModalOpen}
@@ -149,7 +144,11 @@ function MainSidebar() {
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
documents.active
? documents.active.isTemplate &&
!documents.active.isDeleted &&
!documents.active.isArchived
: undefined
}
/>
<ArchiveLink documents={documents} />
+10
View File
@@ -11,6 +11,7 @@ import {
LinkIcon,
TeamIcon,
ExpandedIcon,
BeakerIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -95,6 +96,15 @@ function SettingsSidebar() {
label={t("Security")}
/>
)}
{can.update &&
env.COLLABORATION_URL &&
env.DEPLOYMENT !== "hosted" && (
<SidebarLink
to="/settings/features"
icon={<BeakerIcon color="currentColor" />}
label={t("Features")}
/>
)}
<SidebarLink
to="/settings/members"
icon={<UserIcon color="currentColor" />}
@@ -3,10 +3,14 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop, useDrag } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
import DocumentReparent from "scenes/DocumentReparent";
import CollectionIcon from "components/CollectionIcon";
import Modal from "components/Modal";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
@@ -36,7 +40,15 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const { t } = useTranslation();
const { search } = useLocation();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [
permissionOpen,
handlePermissionOpen,
handlePermissionClose,
] = useBoolean();
const itemRef = React.useRef();
const handleTitleChange = React.useCallback(
async (name: string) => {
@@ -52,12 +64,17 @@ function CollectionLink({
);
React.useEffect(() => {
// If we're viewing a starred document through the starred menu then don't
// touch the expanded / collapsed state of the collections
if (search === "?starred") {
return;
}
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
}
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
@@ -67,9 +84,22 @@ function CollectionLink({
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
const { id, collectionId } = item;
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id);
if (collection.id === collectionId) return;
const prevCollection = collections.get(collectionId);
if (
prevCollection &&
prevCollection.permission === null &&
prevCollection.permission !== collection.permission
) {
itemRef.current = item;
handlePermissionOpen();
} else {
documents.move(id, collection.id);
}
},
canDrop: (item, monitor) => {
return policies.abilities(collection.id).update;
@@ -159,6 +189,7 @@ function CollectionLink({
/>
}
exact={false}
depth={0.5}
menu={
<>
{can.update && (
@@ -198,10 +229,22 @@ function CollectionLink({
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
depth={2}
index={index}
/>
))}
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
isOpen={permissionOpen}
>
<DocumentReparent
item={itemRef.current}
collection={collection}
onSubmit={handlePermissionClose}
onCancel={handlePermissionClose}
/>
</Modal>
</>
);
}
@@ -1,16 +1,16 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import { PlusIcon, CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
@@ -25,6 +25,7 @@ function Collections({ onCreateCollection }: Props) {
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
const { showToast } = useToasts();
const [expanded, setExpanded] = React.useState(true);
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
@@ -99,6 +100,7 @@ function Collections({ onCreateCollection }: Props) {
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
depth={0.5}
/>
)}
</>
@@ -107,7 +109,11 @@ function Collections({ onCreateCollection }: Props) {
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<SidebarLink
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
disabled
/>
<PlaceholderCollections />
</Flex>
);
@@ -115,10 +121,19 @@ function Collections({ onCreateCollection }: Props) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
{isPreloaded ? content : <Fade>{content}</Fade>}
<SidebarLink
onClick={() => setExpanded((prev) => !prev)}
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (isPreloaded ? content : <Fade>{content}</Fade>)}
</Flex>
);
}
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Collections);
@@ -0,0 +1,13 @@
// @flow
import { CollapsedIcon } from "outline-icons";
import styled from "styled-components";
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default Disclosure;
@@ -1,6 +1,5 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
@@ -8,6 +7,7 @@ import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
import Fade from "components/Fade";
import Disclosure from "./Disclosure";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
@@ -128,7 +128,12 @@ function DocumentLink(
// Draggable
const [{ isDragging }, drag] = useDrag({
type: "document",
item: () => ({ ...node, depth, active: isActiveDocument }),
item: () => ({
...node,
depth,
active: isActiveDocument,
collectionId: collection?.id || "",
}),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
@@ -210,7 +215,7 @@ function DocumentLink(
return (
<>
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
<Relative onDragLeave={resetHoverExpanding}>
<Draggable
key={node.id}
ref={drag}
@@ -240,10 +245,14 @@ function DocumentLink(
/>
</>
}
isActive={(match, location) =>
match && location.search !== "?starred"
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
scrollIntoViewIfNeeded={!document?.isStarred}
ref={ref}
menu={
document && !isMoving ? (
@@ -263,7 +272,7 @@ function DocumentLink(
{manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
</Relative>
{expanded && !isDragging && (
<>
{node.children.map((childNode, index) => (
@@ -285,17 +294,13 @@ function DocumentLink(
);
}
const Draggable = styled("div")`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
const Relative = styled.div`
position: relative;
`;
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
const Draggable = styled.div`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
`;
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
@@ -27,7 +27,7 @@ const Cursor = styled("div")`
width: 100%;
height: 14px;
${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")}
${(props) => (props.from === "collections" ? "top: 25px;" : "bottom: -7px;")}
background: transparent;
::after {
+2 -1
View File
@@ -5,10 +5,11 @@ import Flex from "components/Flex";
const Header = styled(Flex)`
font-size: 11px;
font-weight: 600;
user-select: none;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 4px 16px;
margin: 4px 12px;
`;
export default Header;
+4 -2
View File
@@ -31,6 +31,7 @@ type Props = {|
activeClassName?: String,
activeStyle?: Object,
className?: string,
scrollIntoViewIfNeeded?: boolean,
exact?: boolean,
isActive?: any,
location?: Location,
@@ -52,6 +53,7 @@ const NavLink = ({
location: locationProp,
strict,
style: styleProp,
scrollIntoViewIfNeeded,
to,
...rest
}: Props) => {
@@ -83,13 +85,13 @@ const NavLink = ({
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
React.useEffect(() => {
if (isActive && linkRef.current) {
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "instant",
});
}
}, [linkRef, isActive]);
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
const props = {
"aria-current": (isActive && ariaCurrent) || null,
@@ -15,6 +15,7 @@ function PlaceholderCollections() {
const Wrapper = styled.div`
margin: 4px 16px;
margin-left: 40px;
width: 75%;
`;
+1 -1
View File
@@ -5,7 +5,7 @@ import Flex from "components/Flex";
const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 0 8px 20px;
margin: 0 8px 12px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
flex-shrink: 0;
@@ -8,7 +8,7 @@ import EventBoundary from "components/EventBoundary";
import NavLink from "./NavLink";
import { type Theme } from "types";
type Props = {
type Props = {|
to?: string | Object,
href?: string | Object,
innerRef?: (?HTMLElement) => void,
@@ -28,7 +28,8 @@ type Props = {
theme: Theme,
exact?: boolean,
depth?: number,
};
scrollIntoViewIfNeeded?: boolean,
|};
function SidebarLink(
{
@@ -49,12 +50,14 @@ function SidebarLink(
history,
match,
className,
scrollIntoViewIfNeeded,
...rest
}: Props,
ref
) {
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
paddingLeft: `${(depth || 0) * 16 + 12}px`,
};
}, [depth]);
@@ -73,6 +76,7 @@ function SidebarLink(
<>
<Link
$isActiveDrop={isActiveDrop}
scrollIntoViewIfNeeded={scrollIntoViewIfNeeded}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
@@ -83,6 +87,7 @@ function SidebarLink(
href={href}
className={className}
ref={ref}
{...rest}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -131,6 +136,7 @@ const Link = styled(NavLink)`
padding: 6px 16px;
border-radius: 4px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
@@ -156,13 +162,11 @@ const Link = styled(NavLink)`
`}
@media (hover: hover) {
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
&:hover + ${Actions}, &:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
svg {
opacity: 0.75;
}
}
@@ -0,0 +1,171 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "components/Flex";
import PlaceholderCollections from "./PlaceholderCollections";
import Section from "./Section";
import SidebarLink from "./SidebarLink";
import StarredLink from "./StarredLink";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
const STARRED_PAGINATION_LIMIT = 10;
const STARRED = "STARRED";
function Starred() {
const [isFetching, setIsFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const [expanded, setExpanded] = React.useState(true);
const [show, setShow] = React.useState("Nothing");
const [offset, setOffset] = React.useState(0);
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
const { showToast } = useToasts();
const { documents } = useStores();
const { t } = useTranslation();
const { fetchStarred, starred } = documents;
const fetchResults = React.useCallback(async () => {
try {
setIsFetching(true);
await fetchStarred({
limit: STARRED_PAGINATION_LIMIT,
offset,
});
} catch (error) {
showToast(t("Starred documents could not be loaded"), {
type: "error",
});
setFetchError(error);
} finally {
setIsFetching(false);
}
}, [fetchStarred, offset, showToast, t]);
useEffect(() => {
let stateInLocal;
try {
stateInLocal = localStorage.getItem(STARRED);
} catch (_) {
// no-op Safari private mode
}
if (!stateInLocal) {
localStorage.setItem(STARRED, expanded ? "true" : "false");
} else {
setExpanded(stateInLocal === "true");
}
}, [expanded]);
useEffect(() => {
setOffset(starred.length);
if (starred.length <= STARRED_PAGINATION_LIMIT) {
setShow("Nothing");
} else if (starred.length >= upperBound) {
setShow("More");
} else if (starred.length < upperBound) {
setShow("Less");
}
}, [starred, upperBound]);
useEffect(() => {
if (offset === 0) {
fetchResults();
}
}, [fetchResults, offset]);
const handleShowMore = React.useCallback(
async (ev) => {
setUpperBound(
(previousUpperBound) => previousUpperBound + STARRED_PAGINATION_LIMIT
);
await fetchResults();
},
[fetchResults]
);
const handleShowLess = React.useCallback((ev) => {
setUpperBound(STARRED_PAGINATION_LIMIT);
setShow("More");
}, []);
const handleExpandClick = React.useCallback(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
try {
localStorage.setItem(STARRED, !expanded ? "true" : "false");
} catch (_) {
// no-op Safari private mode
}
setExpanded((prev) => !prev);
},
[expanded]
);
const content = starred.slice(0, upperBound).map((document, index) => {
return (
<StarredLink
key={document.id}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
url={document.url}
depth={2}
/>
);
});
if (!starred.length) {
return null;
}
return (
<Section>
<Flex column>
<SidebarLink
onClick={handleExpandClick}
label={t("Starred")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (
<>
{content}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={2}
/>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={2}
/>
)}
{(isFetching || fetchError) && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</>
)}
</Flex>
</Section>
);
}
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Starred);
@@ -0,0 +1,127 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Fade from "components/Fade";
import useStores from "../../../hooks/useStores";
import Disclosure from "./Disclosure";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import DocumentMenu from "menus/DocumentMenu";
type Props = {|
depth: number,
title: string,
to: string,
documentId: string,
collectionId: string,
|};
function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
const { t } = useTranslation();
const { collections, documents, policies } = useStores();
const collection = collections.get(collectionId);
const document = documents.get(documentId);
const [expanded, setExpanded] = useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const canUpdate = policies.abilities(documentId).update;
const childDocuments = collection
? collection.getDocumentChildren(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
useEffect(() => {
async function load() {
if (!document) {
await documents.fetch(documentId);
}
}
load();
}, [collection, collectionId, collections, document, documentId, documents]);
const handleDisclosureClick = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
}, []);
const handleTitleChange = React.useCallback(
async (title: string) => {
if (!document) return;
await documents.update({
id: document.id,
lastRevision: document.revision,
text: document.text,
title,
});
},
[documents, document]
);
return (
<>
<Relative>
<SidebarLink
depth={depth}
to={`${to}?starred`}
isActive={(match, location) =>
match && location.search === "?starred"
}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
</>
}
exact={false}
showActions={menuOpen}
menu={
document ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Relative>
{expanded &&
childDocuments.map((childDocument) => (
<ObserveredStarredLink
key={childDocument.id}
depth={depth + 1}
title={childDocument.title}
to={childDocument.url}
documentId={childDocument.id}
collectionId={collectionId}
/>
))}
</>
);
}
const Relative = styled.div`
position: relative;
`;
const ObserveredStarredLink = observer(StarredLink);
export default ObserveredStarredLink;
@@ -1,4 +1,5 @@
// @flow
import { observer } from "mobx-react";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -84,4 +85,4 @@ const Header = styled.button`
}
`;
export default TeamButton;
export default observer(TeamButton);
+20 -1
View File
@@ -8,6 +8,7 @@ import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import DocumentsStore from "stores/DocumentsStore";
import FileOperationsStore from "stores/FileOperationsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
@@ -28,6 +29,7 @@ type Props = {
views: ViewsStore,
auth: AuthStore,
toasts: ToastsStore,
fileOperations: FileOperationsStore,
};
@observer
@@ -80,6 +82,7 @@ class SocketProvider extends React.Component<Props> {
policies,
presence,
views,
fileOperations,
} = this.props;
if (!auth.token) return;
@@ -287,6 +290,21 @@ class SocketProvider extends React.Component<Props> {
}
});
this.socket.on("fileOperations.update", async (event) => {
const user = auth.user;
let collection = null;
if (event.collectionId)
collection = await collections.fetch(event.collectionId);
if (user) {
fileOperations.add({
...event,
user,
collection,
});
}
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event) => {
@@ -345,5 +363,6 @@ export default inject(
"memberships",
"presence",
"policies",
"views"
"views",
"fileOperations"
)(SocketProvider);
+4 -17
View File
@@ -1,25 +1,13 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import { NavLink, Route } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
import styled, { useTheme } from "styled-components";
import NavLinkWithChildrenFunc from "components/NavLink";
type Props = {
theme: Theme,
children: React.Node,
};
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} exact={exact} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative;
display: inline-flex;
@@ -53,7 +41,8 @@ const transition = {
damping: 30,
};
function Tab({ theme, children, ...rest }: Props) {
export default function Tab({ children, ...rest }: Props) {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
};
@@ -75,5 +64,3 @@ function Tab({ theme, children, ...rest }: Props) {
</TabLink>
);
}
export default withTheme(Tab);
+55 -16
View File
@@ -1,4 +1,5 @@
// @flow
import { isEqual } from "lodash";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
@@ -24,6 +25,7 @@ export type Props = {|
onChangePage: (index: number) => void,
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
columns: any,
defaultSortDirection: "ASC" | "DESC",
|};
function Table({
@@ -39,6 +41,7 @@ function Table({
topRef,
onChangeSort,
onChangePage,
defaultSortDirection,
}: Props) {
const { t } = useTranslation();
const {
@@ -62,32 +65,52 @@ function Table({
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [{ id: defaultSort, desc: false }],
sortBy: [
{
id: defaultSort,
desc: defaultSortDirection === "DESC" ? true : false,
},
],
pageSize,
pageIndex: page,
},
stateReducer: (newState, action, prevState) => {
if (!isEqual(newState.sortBy, prevState.sortBy)) {
return { ...newState, pageIndex: 0 };
}
return newState;
},
},
useSortBy,
usePagination
);
React.useEffect(() => {
onChangePage(pageIndex);
}, [pageIndex]);
const prevSortBy = React.useRef(sortBy);
React.useEffect(() => {
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
sortBy.length && sortBy[0].desc ? "DESC" : "ASC"
);
}, [sortBy]);
if (!isEqual(sortBy, prevSortBy.current)) {
prevSortBy.current = sortBy;
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
);
}
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
const handleNextPage = () => {
nextPage();
onChangePage(pageIndex + 1);
};
const handlePreviousPage = () => {
previousPage();
onChangePage(pageIndex - 1);
};
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
console.log({ canNextPage, pageIndex, totalPages, rows, data });
return (
<>
<Anchor ref={topRef} />
@@ -142,12 +165,12 @@ function Table({
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={previousPage} neutral>
<Button onClick={handlePreviousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={nextPage} neutral>
<Button onClick={handleNextPage} neutral>
{t("Next page")}
</Button>
)}
@@ -209,7 +232,7 @@ const SortWrapper = styled(Flex)`
`;
const Cell = styled.td`
padding: 8px 0;
padding: 6px;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
@@ -226,6 +249,14 @@ const Cell = styled.td`
`;
const Row = styled.tr`
${Cell} {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
&:last-child {
${Cell} {
border-bottom: 0;
@@ -237,7 +268,7 @@ const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px 0;
padding: 6px;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
@@ -245,6 +276,14 @@ const Head = styled.th`
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
z-index: 1;
:first-child {
padding-left: 0;
}
:last-child {
padding-right: 0;
}
`;
export default observer(Table);
+1
View File
@@ -11,6 +11,7 @@ type Props = {
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
format?: string,
shorten?: boolean,
};
+9
View File
@@ -0,0 +1,9 @@
// @flow
import invariant from "invariant";
import useStores from "./useStores";
export default function useCurrentToken() {
const { auth } = useStores();
invariant(auth.token, "token is required");
return auth.token;
}
+54
View File
@@ -0,0 +1,54 @@
// @flow
import * as React from "react";
const activityEvents = [
"click",
"mousemove",
"keydown",
"DOMMouseScroll",
"mousewheel",
"mousedown",
"touchstart",
"touchmove",
"focus",
];
/**
* Hook to detect user idle state.
*
* @param {number} timeToIdle
* @returns boolean if the user is idle
*/
export default function useIdle(timeToIdle: number = 3 * 60 * 1000) {
const [isIdle, setIsIdle] = React.useState(false);
const timeout = React.useRef();
const onActivity = React.useCallback(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setIsIdle(true);
}, timeToIdle);
}, [timeToIdle]);
React.useEffect(() => {
const handleUserActivityEvent = () => {
setIsIdle(false);
onActivity();
};
activityEvents.forEach((eventName) =>
window.addEventListener(eventName, handleUserActivityEvent)
);
return () => {
activityEvents.forEach((eventName) =>
window.removeEventListener(eventName, handleUserActivityEvent)
);
};
}, [onActivity]);
return isIdle;
}
+140
View File
@@ -0,0 +1,140 @@
// @flow
import { Search } from "js-search";
import { last } from "lodash";
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { type DocumentPath } from "stores/CollectionsStore";
import Document from "models/Document";
import PathToDocument from "components/PathToDocument";
import useStores from "hooks/useStores";
export default function useListDocumentPath(document: Document) {
const { collections, documents, policies } = useStores();
const [searchTerm, setSearchTerm] = useState<string>("");
const [selectedPath, setSelectedPath] = useState<?DocumentPath>();
const searchIndex = useMemo(() => {
let paths = collections.pathsToDocuments;
paths = paths.filter((path) => {
if (
(path.type === "collection" && policies.abilities(path.id).update) ||
(path.type === "document" &&
policies.abilities(path.collectionId).update)
)
return true;
return false;
});
const index = new Search("id");
index.addIndex("title");
// Build index
const indexeableDocuments = [];
paths.forEach((path) => {
const doc = documents.get(path.id);
if (!doc || !doc.isTemplate) {
indexeableDocuments.push(path);
}
});
index.addDocuments(indexeableDocuments);
return index;
}, [collections.pathsToDocuments, policies, documents]);
const selected = useCallback(
(result: DocumentPath) => {
if (!selectedPath) return;
if (selectedPath.type === "collection" && selectedPath.id === result.id) {
return true;
}
if (
selectedPath.type === "document" &&
selectedPath.id === result.id &&
selectedPath.collectionId === result.collectionId
) {
return true;
}
return false;
},
[selectedPath]
);
const results: DocumentPath[] = useMemo(() => {
const onlyShowCollections = document.isTemplate;
let results = [];
if (collections.isLoaded) {
if (searchTerm) {
results = searchIndex.search(searchTerm);
} else {
results = searchIndex._documents;
}
}
if (onlyShowCollections) {
results = results.filter((result) => result.type === "collection");
} else {
// Exclude root from search results if document is already at the root
if (!document.parentDocumentId) {
results = results.filter(
(result) => result.id !== document.collectionId
);
}
// Exclude document if on the path to result, or the same result
results = results.filter(
(result) =>
!result.path.map((doc) => doc.id).includes(document.id) &&
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
);
}
return results;
}, [
document.isTemplate,
document.parentDocumentId,
document.collectionId,
document.id,
collections.isLoaded,
searchTerm,
searchIndex,
]);
const row = React.useCallback(
({
index,
data,
style,
}: {
index: number,
data: Array<DocumentPath>,
style: Object,
}) => {
const result = data[index];
return (
<PathToDocument
key={result.url}
result={result}
document={document}
collection={collections.get(result.collectionId)}
setSelectedPath={setSelectedPath}
style={style}
selected={selected(result)}
/>
);
},
[collections, document, selected]
);
return {
row,
results,
searchTerm,
setSearchTerm,
selectedPath,
setSelectedPath,
};
}
+32
View File
@@ -0,0 +1,32 @@
// @flow
import * as React from "react";
import { type ElementRef } from "reakit";
import useMobile from "hooks/useMobile";
import useWindowSize from "hooks/useWindowSize";
const useMenuHeight = (
visible: void | boolean,
unstable_disclosureRef: void | { current: null | ElementRef<"button"> }
) => {
const [maxHeight, setMaxHeight] = React.useState(undefined);
const isMobile = useMobile();
const { height: windowHeight } = useWindowSize();
React.useLayoutEffect(() => {
const padding = 8;
if (visible && !isMobile) {
setMaxHeight(
unstable_disclosureRef?.current
? windowHeight -
unstable_disclosureRef.current.getBoundingClientRect().bottom -
padding
: undefined
);
}
}, [visible, unstable_disclosureRef, windowHeight, isMobile]);
return maxHeight;
};
export default useMenuHeight;
+22
View File
@@ -0,0 +1,22 @@
// @flow
import * as React from "react";
/**
* Hook to return page visibility state.
*
* @returns boolean if the page is visible
*/
export default function usePageVisibility(): boolean {
const [visible, setVisible] = React.useState(true);
React.useEffect(() => {
const handleVisibilityChange = () => setVisible(!document.hidden);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return visible;
}
+29
View File
@@ -0,0 +1,29 @@
// @flow
import * as React from "react";
import { getCookie } from "tiny-cookie";
type Session = {|
url: string,
logoUrl: string,
name: string,
teamId: string,
|};
function loadSessionsFromCookie(): Session[] {
const sessions = JSON.parse(getCookie("sessions") || "{}");
return Object.keys(sessions).map((teamId) => ({
teamId,
...sessions[teamId],
}));
}
export default function useSessions() {
const [sessions, setSessions] = React.useState(loadSessionsFromCookie);
const reload = React.useCallback(() => {
setSessions(loadSessionsFromCookie());
}, []);
return [sessions, reload];
}
+4 -4
View File
@@ -4,8 +4,8 @@ import * as React from "react";
export default function useWindowSize() {
const [windowSize, setWindowSize] = React.useState({
width: undefined,
height: undefined,
width: parseInt(window.innerWidth),
height: parseInt(window.innerHeight),
});
React.useEffect(() => {
@@ -13,8 +13,8 @@ export default function useWindowSize() {
const handleResize = debounce(() => {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
width: parseInt(window.innerWidth),
height: parseInt(window.innerHeight),
});
}, 100);
+1 -1
View File
@@ -15,8 +15,8 @@ import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme";
import Toasts from "components/Toasts";
import Routes from "./routes";
import { initSentry } from "./utils/sentry";
import env from "env";
import { initSentry } from "utils/sentry";
initI18n();
+137 -82
View File
@@ -1,85 +1,48 @@
// @flow
import { observer } from "mobx-react";
import { SunIcon, MoonIcon } from "outline-icons";
import { MoonIcon, SunIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import {
developers,
changelog,
developers,
githubIssuesUrl,
mailToUrl,
settings,
} 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 Template from "components/ContextMenu/Template";
import Guide from "components/Guide";
import env from "env";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import usePrevious from "hooks/usePrevious";
import useSessions from "hooks/useSessions";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { deleteAllDatabases } from "utils/developer";
type Props = {|
children: (props: any) => React.Node,
|};
const AppearanceMenu = React.forwardRef((props, ref) => {
const { ui } = useStores();
const { t } = useTranslation();
const menu = useMenuState();
return (
<>
<MenuButton ref={ref} {...menu} {...props} onClick={menu.show}>
{(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"}
>
{t("System")}
</MenuItem>
<MenuItem
{...menu}
onClick={() => ui.setTheme("light")}
selected={ui.theme === "light"}
>
{t("Light")}
</MenuItem>
<MenuItem
{...menu}
onClick={() => ui.setTheme("dark")}
selected={ui.theme === "dark"}
>
{t("Dark")}
</MenuItem>
</ContextMenu>
</>
);
});
function AccountMenu(props: Props) {
const [sessions] = useSessions();
const menu = useMenuState({
unstable_offset: [8, 0],
placement: "bottom-start",
modal: true,
});
const { showToast } = useToasts();
const { auth, ui } = useStores();
const previousTheme = usePrevious(ui.theme);
const { theme, resolvedTheme } = ui;
const team = useCurrentTeam();
const previousTheme = usePrevious(theme);
const { t } = useTranslation();
const [includeAlt, setIncludeAlt] = React.useState(false);
const [
keyboardShortcutsOpen,
handleKeyboardShortcutsOpen,
@@ -87,10 +50,122 @@ function AccountMenu(props: Props) {
] = useBoolean();
React.useEffect(() => {
if (ui.theme !== previousTheme) {
if (theme !== previousTheme) {
menu.hide();
}
}, [menu, ui.theme, previousTheme]);
}, [menu, theme, previousTheme]);
const handleDeleteAllDatabases = React.useCallback(async () => {
await deleteAllDatabases();
showToast("IndexedDB cache deleted");
menu.hide();
}, [showToast, menu]);
const handleOpenMenu = React.useCallback((event) => {
setIncludeAlt(event.altKey);
}, []);
const items = React.useMemo(() => {
const otherSessions = sessions.filter(
(session) => session.teamId !== team.id && session.url !== team.url
);
return [
{
title: t("Settings"),
to: settings(),
},
{
title: t("Keyboard shortcuts"),
onClick: handleKeyboardShortcutsOpen,
},
{
title: t("API documentation"),
href: developers(),
},
{
type: "separator",
},
{
title: t("Changelog"),
href: changelog(),
},
{
title: t("Send us feedback"),
href: mailToUrl(),
},
{
title: t("Report a bug"),
href: githubIssuesUrl(),
},
...(includeAlt || env.ENVIRONMENT === "development"
? [
{
title: t("Development"),
items: [
{
title: "Delete IndexedDB cache",
icon: <TrashIcon />,
onClick: handleDeleteAllDatabases,
},
],
},
]
: []),
{
title: t("Appearance"),
icon: resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
items: [
{
title: t("System"),
onClick: () => ui.setTheme("system"),
selected: theme === "system",
},
{
title: t("Light"),
onClick: () => ui.setTheme("light"),
selected: theme === "light",
},
{
title: t("Dark"),
onClick: () => ui.setTheme("dark"),
selected: theme === "dark",
},
],
},
{
type: "separator",
},
...(otherSessions.length
? [
{
title: t("Switch team"),
items: otherSessions.map((session) => ({
title: session.name,
icon: <Logo alt={session.name} src={session.logoUrl} />,
href: session.url,
})),
},
]
: []),
{
title: t("Log out"),
onClick: auth.logout,
},
];
}, [
auth.logout,
team.id,
team.url,
sessions,
handleKeyboardShortcutsOpen,
handleDeleteAllDatabases,
resolvedTheme,
includeAlt,
theme,
t,
ui,
]);
return (
<>
@@ -101,40 +176,20 @@ function AccountMenu(props: Props) {
>
<KeyboardShortcuts />
</Guide>
<MenuButton {...menu}>{props.children}</MenuButton>
<MenuButton {...menu} onClick={handleOpenMenu}>
{props.children}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Account")}>
<MenuItem {...menu} as={Link} to={settings()}>
{t("Settings")}
</MenuItem>
<MenuItem {...menu} onClick={handleKeyboardShortcutsOpen}>
{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>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
const ChangeTheme = styled(Flex)`
width: 100%;
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export default observer(AccountMenu);
+67 -42
View File
@@ -1,5 +1,13 @@
// @flow
import { observer } from "mobx-react";
import {
NewDocumentIcon,
EditIcon,
TrashIcon,
ImportIcon,
ExportIcon,
PadlockIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
@@ -12,8 +20,9 @@ import CollectionExport from "scenes/CollectionExport";
import CollectionPermissions from "scenes/CollectionPermissions";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
import Template from "components/ContextMenu/Template";
import Modal from "components/Modal";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
@@ -38,6 +47,7 @@ function CollectionMenu({
}: Props) {
const menu = useMenuState({ modal, placement });
const [renderModals, setRenderModals] = React.useState(false);
const team = useCurrentTeam();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
@@ -112,50 +122,65 @@ function CollectionMenu({
);
const can = policies.abilities(collection.id);
const canUserInTeam = policies.abilities(team.id);
const items = React.useMemo(
() =>
filterTemplateItems([
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]),
[can, collection, handleNewDocument, handleImportDocument, t]
() => [
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
icon: <NewDocumentIcon />,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
icon: <ImportIcon />,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
icon: <EditIcon />,
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
icon: <PadlockIcon />,
},
{
title: `${t("Export")}`,
visible: !!(collection && canUserInTeam.export),
onClick: () => setShowCollectionExport(true),
icon: <ExportIcon />,
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
icon: <TrashIcon />,
},
],
[
t,
can.update,
can.delete,
handleNewDocument,
handleImportDocument,
collection,
canUserInTeam.export,
]
);
if (!items.length) {
if (!items.some((item) => item.visible)) {
return null;
}
+103 -38
View File
@@ -1,5 +1,25 @@
// @flow
import { observer } from "mobx-react";
import {
EditIcon,
PinIcon,
StarredIcon,
UnstarredIcon,
DuplicateIcon,
ArchiveIcon,
TrashIcon,
MoveIcon,
HistoryIcon,
UnpublishIcon,
ShapesIcon,
PrintIcon,
ImportIcon,
NewDocumentIcon,
DownloadIcon,
BuildingBlocksIcon,
RestoreIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
@@ -17,6 +37,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
@@ -52,6 +73,7 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const team = useCurrentTeam();
const { policies, collections, documents } = useStores();
const { showToast } = useToasts();
const menu = useMenuState({
@@ -114,9 +136,13 @@ function DocumentMenu({
[showToast, t, document]
);
const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
window.print();
}, []);
const handlePrint = React.useCallback(
(ev: SyntheticEvent<>) => {
menu.hide();
window.print();
},
[menu]
);
const handleStar = React.useCallback(
(ev: SyntheticEvent<>) => {
@@ -136,9 +162,32 @@ function DocumentMenu({
[document]
);
const collection = collections.get(document.collectionId);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const can = policies.abilities(document.id);
const canViewHistory = can.read && !can.restore;
const restoreItems = React.useMemo(
() => [
...collections.orderedData.reduce((filtered, collection) => {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
onClick: (ev) => handleRestore(ev, { collectionId: collection.id }),
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
});
}
return filtered;
}, []),
],
[collections.orderedData, handleRestore, policies]
);
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
ev.stopPropagation();
@@ -225,124 +274,137 @@ function DocumentMenu({
items={[
{
title: t("Restore"),
visible: (!!collection && can.restore) || can.unarchive,
visible: !!collection && (can.restore || can.unarchive),
onClick: handleRestore,
icon: <RestoreIcon />,
},
{
title: t("Restore"),
visible: !collection && !!can.restore,
visible:
!collection &&
(can.restore || can.unarchive) &&
restoreItems.length !== 0,
style: {
left: -170,
position: "relative",
top: -40,
},
icon: <RestoreIcon />,
hover: true,
items: [
{
type: "heading",
title: t("Choose a collection"),
},
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return {
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
onClick: (ev) =>
handleRestore(ev, { collectionId: collection.id }),
disabled: !can.update,
};
}),
...restoreItems,
],
},
{
title: t("Unpin"),
onClick: document.unpin,
visible: !!(showPin && document.pinned && can.unpin),
icon: <PinIcon />,
},
{
title: t("Pin to collection"),
onClick: document.pin,
visible: !!(showPin && !document.pinned && can.pin),
icon: <PinIcon />,
},
{
title: t("Unstar"),
onClick: handleUnstar,
visible: document.isStarred && !!can.unstar,
icon: <UnstarredIcon />,
},
{
title: t("Star"),
onClick: handleStar,
visible: !document.isStarred && !!can.star,
},
{
title: t("Enable embeds"),
onClick: document.enableEmbeds,
visible: !!showToggleEmbeds && document.embedsDisabled,
},
{
title: t("Disable embeds"),
onClick: document.disableEmbeds,
visible: !!showToggleEmbeds && !document.embedsDisabled,
icon: <StarredIcon />,
},
{
type: "separator",
},
{
title: t("Edit"),
to: editDocumentUrl(document),
visible: !!can.update,
icon: <EditIcon />,
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
to: newDocumentUrl(collection?.id, {
parentDocumentId: document.id,
}),
visible: !!can.createChildDocument,
visible: !!can.createChildDocument && !!collection?.id,
icon: <NewDocumentIcon />,
},
{
title: t("Import document"),
visible: can.createChildDocument,
onClick: handleImportDocument,
icon: <ImportIcon />,
},
{
title: `${t("Create template")}`,
onClick: () => setShowTemplateModal(true),
visible: !!can.update && !document.isTemplate,
},
{
title: t("Edit"),
to: editDocumentUrl(document),
visible: !!can.update,
icon: <ShapesIcon />,
},
{
title: t("Duplicate"),
onClick: handleDuplicate,
visible: !!can.update,
icon: <DuplicateIcon />,
},
{
title: t("Unpublish"),
onClick: handleUnpublish,
visible: !!can.unpublish,
icon: <UnpublishIcon />,
},
{
title: t("Archive"),
onClick: handleArchive,
visible: !!can.archive,
icon: <ArchiveIcon />,
},
{
title: `${t("Delete")}`,
onClick: () => setShowDeleteModal(true),
visible: !!can.delete,
icon: <TrashIcon />,
},
{
title: `${t("Permanently delete")}`,
onClick: () => setShowPermanentDeleteModal(true),
visible: can.permanentDelete,
icon: <CrossIcon />,
},
{
title: `${t("Move")}`,
onClick: () => setShowMoveModal(true),
visible: !!can.move,
icon: <MoveIcon />,
},
{
title: t("Enable embeds"),
onClick: document.enableEmbeds,
visible:
!!showToggleEmbeds &&
document.embedsDisabled &&
!team.collaborativeEditing,
icon: <BuildingBlocksIcon />,
},
{
title: t("Disable embeds"),
onClick: document.disableEmbeds,
visible:
!!showToggleEmbeds &&
!document.embedsDisabled &&
!team.collaborativeEditing,
icon: <BuildingBlocksIcon />,
},
{
type: "separator",
@@ -353,16 +415,19 @@ function DocumentMenu({
? documentUrl(document)
: documentHistoryUrl(document),
visible: canViewHistory,
icon: <HistoryIcon />,
},
{
title: t("Download"),
onClick: document.download,
visible: !!can.download,
icon: <DownloadIcon />,
},
{
title: t("Print"),
onClick: handlePrint,
visible: !!showPrint,
icon: <PrintIcon />,
},
]}
/>
+43
View File
@@ -0,0 +1,43 @@
// @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 = {|
id: string,
onDelete: (ev: SyntheticEvent<>) => Promise<void>,
|};
function FileOperationMenu({ id, onDelete }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Export options")}>
<Template
{...menu}
items={[
{
title: t("Download"),
href: "/api/fileOperations.redirect?id=" + id,
},
{
type: "separator",
},
{
title: t("Delete"),
onClick: onDelete,
},
]}
/>
</ContextMenu>
</>
);
}
export default FileOperationMenu;
+7 -3
View File
@@ -18,9 +18,13 @@ function NewChildDocumentMenu({ document, label }: Props) {
const menu = useMenuState({ modal: true });
const { collections } = useStores();
const { t } = useTranslation();
const collection = collections.get(document.collectionId);
const collection = collections.get(document.collectionId || "");
const collectionName = collection ? collection.name : t("collection");
if (!collection) {
return null;
}
return (
<>
<MenuButton {...menu}>{label}</MenuButton>
@@ -38,11 +42,11 @@ function NewChildDocumentMenu({ document, label }: Props) {
/>
</span>
),
to: newDocumentUrl(document.collectionId),
to: newDocumentUrl(collection.id),
},
{
title: t("New nested document"),
to: newDocumentUrl(document.collectionId, {
to: newDocumentUrl(collection.id, {
parentDocumentId: document.id,
}),
},
-78
View File
@@ -1,78 +0,0 @@
// @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 { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import ContextMenu from "components/ContextMenu";
import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
function NewDocumentMenu() {
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const singleCollection = collections.orderedData.length === 1;
const can = policies.abilities(team.id);
if (!can.createDocument) {
return null;
}
if (singleCollection) {
return (
<Button
as={Link}
to={newDocumentUrl(collections.orderedData[0].id)}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
);
}
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button icon={<PlusIcon />} {...props} small>
{`${t("New doc")}`}
</Button>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("New document")}>
<Header>{t("Choose a collection")}</Header>
<Template
{...menu}
items={collections.orderedData.map((collection) => ({
to: newDocumentUrl(collection.id),
disabled: !policies.abilities(collection.id).update,
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
}))}
/>
</ContextMenu>
</>
);
}
const CollectionName = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(NewDocumentMenu);
+19 -18
View File
@@ -3,14 +3,13 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import ContextMenu from "components/ContextMenu";
import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers";
@@ -22,7 +21,23 @@ function NewTemplateMenu() {
const { collections, policies } = useStores();
const can = policies.abilities(team.id);
if (!can.createDocument) {
const items = React.useMemo(
() =>
collections.orderedData.reduce((filtered, collection) => {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
to: newDocumentUrl(collection.id, { template: true }),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <CollectionIcon collection={collection} />,
});
}
return filtered;
}, []),
[collections.orderedData, policies]
);
if (!can.createDocument || items.length === 0) {
return null;
}
@@ -37,21 +52,7 @@ function NewTemplateMenu() {
</MenuButton>
<ContextMenu aria-label={t("New template")} {...menu}>
<Header>{t("Choose a collection")}</Header>
<Template
{...menu}
items={collections.orderedData.map((collection) => ({
to: newDocumentUrl(collection.id, {
template: true,
}),
disabled: !policies.abilities(collection.id).update,
title: (
<Flex align="center">
<CollectionIcon collection={collection} />
<CollectionName>{collection.name}</CollectionName>
</Flex>
),
}))}
/>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
+24 -10
View File
@@ -1,28 +1,30 @@
// @flow
import { observer } from "mobx-react";
import { RestoreIcon, LinkIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import Document from "models/Document";
import Revision from "models/Revision";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard";
import MenuIconWrapper from "components/MenuIconWrapper";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
revision: Revision,
iconColor?: string,
revisionId: string,
className?: string,
|};
function RevisionMenu({ document, revision, className, iconColor }: Props) {
function RevisionMenu({ document, revisionId, className }: Props) {
const { showToast } = useToasts();
const team = useCurrentTeam();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const history = useHistory();
@@ -30,11 +32,11 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await document.restore({ revisionId: revision.id });
await document.restore({ revisionId });
showToast(t("Document restored"), { type: "success" });
history.push(document.url);
},
[history, showToast, t, document, revision]
[history, showToast, t, document, revisionId]
);
const handleCopy = React.useCallback(() => {
@@ -43,24 +45,36 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
const url = `${window.location.origin}${documentHistoryUrl(
document,
revision.id
revisionId
)}`;
return (
<>
<OverflowMenuButton
className={className}
iconColor={iconColor}
iconColor="currentColor"
aria-label={t("Show menu")}
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Revision options")}>
<MenuItem {...menu} onClick={handleRestore}>
<MenuItem
{...menu}
onClick={handleRestore}
disabled={team.collaborativeEditing}
>
<MenuIconWrapper>
<RestoreIcon />
</MenuIconWrapper>
{t("Restore version")}
</MenuItem>
<Separator />
<CopyToClipboard text={url} onCopy={handleCopy}>
<MenuItem {...menu}>{t("Copy link")}</MenuItem>
<MenuItem {...menu}>
<MenuIconWrapper>
<LinkIcon />
</MenuIconWrapper>
{t("Copy link")}
</MenuItem>
</CopyToClipboard>
</ContextMenu>
</>
+4 -3
View File
@@ -14,9 +14,10 @@ import useStores from "hooks/useStores";
type Props = {|
document: Document,
onSelectTemplate: (template: Document) => void,
|};
function TemplatesMenu({ document }: Props) {
function TemplatesMenu({ onSelectTemplate, document }: Props) {
const menu = useMenuState({ modal: true });
const { documents } = useStores();
const { t } = useTranslation();
@@ -36,10 +37,10 @@ function TemplatesMenu({ document }: Props) {
const renderTemplate = (template) => (
<MenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
onClick={() => onSelectTemplate(template)}
icon={<DocumentIcon />}
{...menu}
>
<DocumentIcon />
<TemplateItem>
<strong>{template.titleWithDefault}</strong>
<br />
+5 -5
View File
@@ -49,7 +49,7 @@ function UserMenu({ user }: Props) {
) {
return;
}
users.demote(user, "Member");
users.demote(user, "member");
},
[users, user, t]
);
@@ -69,7 +69,7 @@ function UserMenu({ user }: Props) {
) {
return;
}
users.demote(user, "Viewer");
users.demote(user, "viewer");
},
[users, user, t]
);
@@ -119,21 +119,21 @@ function UserMenu({ user }: Props) {
userName: user.name,
}),
onClick: handleMember,
visible: can.demote && user.rank !== "Member",
visible: can.demote && user.role !== "member",
},
{
title: t("Make {{ userName }} a viewer", {
userName: user.name,
}),
onClick: handleViewer,
visible: can.demote && user.rank !== "Viewer",
visible: can.demote && user.role !== "viewer",
},
{
title: t("Make {{ userName }} an admin…", {
userName: user.name,
}),
onClick: handlePromote,
visible: can.promote && user.rank !== "Admin",
visible: can.promote && user.role !== "admin",
},
{
type: "separator",
+1 -5
View File
@@ -124,10 +124,6 @@ export default class Collection extends BaseModel {
};
export = () => {
return client.get(
"/collections.export",
{ id: this.id },
{ download: true }
);
return client.get("/collections.export", { id: this.id });
};
}
+32 -18
View File
@@ -10,22 +10,21 @@ import BaseModel from "models/BaseModel";
import User from "models/User";
import View from "./View";
type SaveOptions = {
type SaveOptions = {|
publish?: boolean,
done?: boolean,
autosave?: boolean,
lastRevision?: number,
};
|};
export default class Document extends BaseModel {
@observable isSaving: boolean = false;
@observable embedsDisabled: boolean = false;
@observable injectTemplate: boolean = false;
@observable lastViewedAt: ?string;
store: DocumentsStore;
collaboratorIds: string[];
collectionId: string;
collectionId: ?string;
createdAt: string;
createdBy: User;
updatedAt: string;
@@ -254,15 +253,28 @@ export default class Document extends BaseModel {
};
@action
updateFromTemplate = async (template: Document) => {
this.templateId = template.id;
this.title = template.title;
this.text = template.text;
this.injectTemplate = true;
update = async (options: {| ...SaveOptions, title: string |}) => {
if (this.isSaving) return this;
this.isSaving = true;
try {
if (options.lastRevision) {
return await this.store.update({
id: this.id,
title: this.title,
lastRevision: options.lastRevision,
...options,
});
}
throw new Error("Attempting to update without a lastRevision");
} finally {
this.isSaving = false;
}
};
@action
save = async (options: SaveOptions = {}) => {
save = async (options: ?SaveOptions) => {
if (this.isSaving) return this;
const isCreating = !this.id;
@@ -275,22 +287,24 @@ export default class Document extends BaseModel {
collectionId: this.collectionId,
title: this.title,
text: this.text,
publish: options.publish,
done: options.done,
autosave: options.autosave,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
});
}
if (options.lastRevision) {
if (options?.lastRevision) {
return await this.store.update({
id: this.id,
title: this.title,
text: this.text,
templateId: this.templateId,
lastRevision: options.lastRevision,
publish: options.publish,
done: options.done,
autosave: options.autosave,
lastRevision: options?.lastRevision,
publish: options?.publish,
done: options?.done,
autosave: options?.autosave,
collectionId: this.collectionId,
parentDocumentId: this.parentDocumentId,
});
}
-14
View File
@@ -20,20 +20,6 @@ class Event extends BaseModel {
published: boolean,
templateId: string,
};
get model() {
return this.name.split(".")[0];
}
get verb() {
return this.name.split(".")[1];
}
get verbPastTense() {
const v = this.verb;
if (v.endsWith("e")) return `${v}d`;
return `${v}ed`;
}
}
export default Event;
+27
View File
@@ -0,0 +1,27 @@
// @flow
import { computed } from "mobx";
import BaseModal from "./BaseModel";
import Collection from "./Collection";
import User from "./User";
class FileOperation extends BaseModal {
id: string;
state: string;
collection: ?Collection;
size: number;
type: string;
user: User;
createdAt: string;
@computed
get sizeInMB(): string {
const inKB = this.size / 1024;
if (inKB < 1024) {
return inKB.toFixed(2) + "KB";
}
return (inKB / 1024).toFixed(2) + "MB";
}
}
export default FileOperation;
+1
View File
@@ -7,6 +7,7 @@ class Team extends BaseModel {
name: string;
avatarUrl: string;
sharing: boolean;
collaborativeEditing: boolean;
documentEmbeds: boolean;
guestSignin: boolean;
subdomain: ?string;
+6 -5
View File
@@ -1,6 +1,6 @@
// @flow
import { computed } from "mobx";
import type { Rank } from "shared/types";
import type { Role } from "shared/types";
import BaseModel from "./BaseModel";
class User extends BaseModel {
@@ -8,6 +8,7 @@ class User extends BaseModel {
id: string;
name: string;
email: string;
color: string;
isAdmin: boolean;
isViewer: boolean;
lastActiveAt: string;
@@ -21,13 +22,13 @@ class User extends BaseModel {
}
@computed
get rank(): Rank {
get role(): Role {
if (this.isAdmin) {
return "Admin";
return "admin";
} else if (this.isViewer) {
return "Viewer";
return "viewer";
} else {
return "Member";
return "member";
}
}
}
+57
View File
@@ -0,0 +1,57 @@
// @flow
import { keymap } from "prosemirror-keymap";
import { Extension } from "rich-markdown-editor";
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "y-prosemirror";
import * as Y from "yjs";
export default class MultiplayerExtension extends Extension {
get name() {
return "multiplayer";
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
const assignUser = (tr) => {
const clientIds = Array.from(doc.store.clients.keys());
if (
tr.local &&
tr.changed.size > 0 &&
!clientIds.includes(doc.clientID)
) {
const permanentUserData = new Y.PermanentUserData(doc);
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
doc.off("afterTransaction", assignUser);
}
};
// only once we have authenticated successfully do we initalize awareness.
// we could send this earlier, but getting authenticated faster is more important
provider.on("authenticated", () => {
provider.setAwarenessField("user", user);
});
// only once an actual change has been made do we add the userId <> clientId
// mapping, this avoids stored mappings for clients that never made a change
doc.on("afterTransaction", assignUser);
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
}
+7 -10
View File
@@ -8,7 +8,6 @@ import Drafts from "scenes/Drafts";
import Error404 from "scenes/Error404";
import Home from "scenes/Home";
import Search from "scenes/Search";
import Starred from "scenes/Starred";
import Templates from "scenes/Templates";
import Trash from "scenes/Trash";
@@ -22,10 +21,8 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
const SettingsRoutes = React.lazy(() =>
import(/* webpackChunkName: "settings" */ "./settings")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const Document = React.lazy(() =>
import(/* webpackChunkName: "document" */ "scenes/Document")
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
@@ -51,13 +48,12 @@ export default function AuthenticatedRoutes() {
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Redirect exact from="/starred" to="/home" />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab" component={Collection} />
@@ -66,10 +62,11 @@ export default function AuthenticatedRoutes() {
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
+4 -6
View File
@@ -12,10 +12,8 @@ const Authenticated = React.lazy(() =>
const AuthenticatedRoutes = React.lazy(() =>
import(/* webpackChunkName: "authenticated-routes" */ "./authenticated")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
const SharedDocument = React.lazy(() =>
import(/* webpackChunkName: "shared-document" */ "scenes/Document/Shared")
);
const Login = React.lazy(() =>
import(/* webpackChunkName: "login" */ "scenes/Login")
@@ -37,11 +35,11 @@ export default function Routes() {
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Route exact path="/share/:shareId" component={SharedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
component={SharedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
+2
View File
@@ -2,6 +2,7 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import Details from "scenes/Settings/Details";
import Features from "scenes/Settings/Features";
import Groups from "scenes/Settings/Groups";
import ImportExport from "scenes/Settings/ImportExport";
import Notifications from "scenes/Settings/Notifications";
@@ -21,6 +22,7 @@ export default function SettingsRoutes() {
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/members" component={People} />
<Route exact path="/settings/features" component={Features} />
<Route exact path="/settings/groups" component={Groups} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
+16 -12
View File
@@ -19,19 +19,23 @@ function APITokenNew({ onSubmit }: Props) {
const { showToast } = useToasts();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
setIsSaving(true);
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
try {
await apiKeys.create({ name });
showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, showToast, name, onSubmit, apiKeys]);
try {
await apiKeys.create({ name });
showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[t, showToast, name, onSubmit, apiKeys]
);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
+1 -1
View File
@@ -50,7 +50,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
/>
</HelpText>
<Button type="submit" disabled={isDeleting} autoFocus danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
{isDeleting ? `${t("Deleting")}` : t("Im sure Delete")}
</Button>
</form>
</Flex>
+85 -123
View File
@@ -1,10 +1,8 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -12,141 +10,105 @@ import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputSelect from "components/InputSelect";
import Switch from "components/Switch";
import useToasts from "hooks/useToasts";
type Props = {
collection: Collection,
toasts: ToastsStore,
auth: AuthStore,
onSubmit: () => void,
t: TFunction,
};
@observer
class CollectionEdit extends React.Component<Props> {
@observable name: string = this.props.collection.name;
@observable sharing: boolean = this.props.collection.sharing;
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable sort: { field: string, direction: "asc" | "desc" } = this.props
.collection.sort;
@observable isSaving: boolean;
const CollectionEdit = ({ collection, onSubmit }: Props) => {
const [name, setName] = useState(collection.name);
const [icon, setIcon] = useState(collection.icon);
const [color, setColor] = useState(collection.color || "#4E5C6E");
const [sort, setSort] = useState<{
field: string,
direction: "asc" | "desc",
}>(collection.sort);
const [isSaving, setIsSaving] = useState();
const { showToast } = useToasts();
const { t } = useTranslation();
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
this.isSaving = true;
const { t } = this.props;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
setIsSaving(true);
try {
await this.props.collection.save({
name: this.name,
icon: this.icon,
color: this.color,
sharing: this.sharing,
sort: this.sort,
});
this.props.onSubmit();
this.props.toasts.showToast(t("The collection was updated"), {
type: "success",
});
} catch (err) {
this.props.toasts.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
try {
await collection.save({
name,
icon,
color,
sort,
});
onSubmit();
showToast(t("The collection was updated"), {
type: "success",
});
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[collection, color, icon, name, onSubmit, showToast, sort, t]
);
handleSortChange = (ev: SyntheticInputEvent<HTMLSelectElement>) => {
const [field, direction] = ev.target.value.split(".");
const handleSortChange = (value: string) => {
const [field, direction] = value.split(".");
if (direction === "asc" || direction === "desc") {
this.sort = { field, direction };
setSort({ field, direction });
}
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
const handleNameChange = (ev: SyntheticInputEvent<*>) => {
setName(ev.target.value.trim());
};
handleChange = (color: string, icon: string) => {
this.color = color;
this.icon = icon;
const handleChange = (color: string, icon: string) => {
setColor(color);
setIcon(icon);
};
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
this.sharing = ev.target.checked;
};
render() {
const { auth, t } = this.props;
const teamSharingEnabled = !!auth.team && auth.team.sharing;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
<Trans>
You can edit the name and other details at any time, however doing
so often might confuse your team mates.
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={this.handleNameChange}
value={this.name}
required
autoFocus
flex
/>
&nbsp;
<IconPicker
onChange={this.handleChange}
color={this.color}
icon={this.icon}
/>
</Flex>
<InputSelect
label={t("Sort in sidebar")}
options={[
{ label: t("Alphabetical"), value: "title.asc" },
{ label: t("Manual sort"), value: "index.asc" },
]}
value={`${this.sort.field}.${this.sort.direction}`}
onChange={this.handleSortChange}
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
You can edit the name and other details at any time, however doing
so often might confuse your team mates.
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
<Switch
id="sharing"
label={t("Public document sharing")}
onChange={this.handleSharingChange}
checked={this.sharing && teamSharingEnabled}
disabled={!teamSharingEnabled}
/>
<HelpText>
{teamSharingEnabled ? (
<Trans>
When enabled, documents can be shared publicly on the internet.
</Trans>
) : (
<Trans>
Public sharing is currently disabled in the team security
settings.
</Trans>
)}
</HelpText>
<Button
type="submit"
disabled={this.isSaving || !this.props.collection.name}
>
{this.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Flex>
);
}
}
&nbsp;
<IconPicker onChange={handleChange} color={color} icon={icon} />
</Flex>
<InputSelect
label={t("Sort in sidebar")}
options={[
{ label: t("Alphabetical"), value: "title.asc" },
{ label: t("Manual sort"), value: "index.asc" },
]}
value={`${sort.field}.${sort.direction}`}
onChange={handleSortChange}
ariaLabel={t("Sort")}
/>
<Button type="submit" disabled={isSaving || !collection.name}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Flex>
);
};
export default withTranslation()<CollectionEdit>(
inject("toasts", "auth")(CollectionEdit)
);
export default observer(CollectionEdit);
+7 -3
View File
@@ -6,7 +6,7 @@ import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
type Props = {
collection: Collection,
onSubmit: () => void,
@@ -15,6 +15,7 @@ type Props = {
function CollectionExport({ collection, onSubmit }: Props) {
const [isLoading, setIsLoading] = React.useState();
const { t } = useTranslation();
const { showToast } = useToasts();
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
@@ -23,9 +24,12 @@ function CollectionExport({ collection, onSubmit }: Props) {
setIsLoading(true);
await collection.export();
setIsLoading(false);
showToast(
t("Export started, you will receive an email when its complete.")
);
onSubmit();
},
[collection, onSubmit]
[collection, onSubmit, showToast, t]
);
return (
@@ -33,7 +37,7 @@ function CollectionExport({ collection, onSubmit }: Props) {
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format."
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be a zip of folders with files in Markdown format. Please visit the Export section on settings to get the zip."
values={{ collectionName: collection.name }}
components={{ em: <strong /> }}
/>
+2 -2
View File
@@ -88,8 +88,8 @@ class CollectionNew extends React.Component<Props> {
this.hasOpenedIconPicker = true;
};
handlePermissionChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.permission = ev.target.value;
handlePermissionChange = (newPermission: string) => {
this.permission = newPermission;
};
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
@@ -5,7 +5,7 @@ import styled from "styled-components";
import CollectionGroupMembership from "models/CollectionGroupMembership";
import Group from "models/Group";
import GroupListItem from "components/GroupListItem";
import InputSelect from "components/InputSelect";
import InputSelect, { type Props as SelectProps } from "components/InputSelect";
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
type Props = {|
@@ -47,8 +47,10 @@ const CollectionGroupMemberListItem = ({
? collectionGroupMembership.permission
: undefined
}
onChange={(ev) => onUpdate(ev.target.value)}
onChange={onUpdate}
ariaLabel={t("Permissions")}
labelHidden
nude
/>
<Spacer />
<CollectionGroupMemberMenu
@@ -65,7 +67,7 @@ const Spacer = styled.div`
width: 8px;
`;
const Select = styled(InputSelect)`
const Select = (styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
@@ -73,6 +75,6 @@ const Select = styled(InputSelect)`
select {
margin: 0;
}
`;
`: React.ComponentType<SelectProps>);
export default CollectionGroupMemberListItem;
@@ -8,7 +8,7 @@ import Avatar from "components/Avatar";
import Badge from "components/Badge";
import Button from "components/Button";
import Flex from "components/Flex";
import InputSelect from "components/InputSelect";
import InputSelect, { type Props as SelectProps } from "components/InputSelect";
import ListItem from "components/List/Item";
import Time from "components/Time";
import MemberMenu from "menus/MemberMenu";
@@ -64,9 +64,11 @@ const MemberListItem = ({
label={t("Permissions")}
options={PERMISSIONS}
value={membership ? membership.permission : undefined}
onChange={(ev) => onUpdate(ev.target.value)}
onChange={onUpdate}
disabled={!canEdit}
ariaLabel={t("Permissions")}
labelHidden
nude
/>
)}
{canEdit && (
@@ -90,7 +92,7 @@ const Spacer = styled.div`
width: 8px;
`;
const Select = styled(InputSelect)`
const Select = (styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
@@ -98,6 +100,6 @@ const Select = styled(InputSelect)`
select {
margin: 0;
}
`;
`: React.ComponentType<SelectProps>);
export default MemberListItem;

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