mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
885 Commits
develop
...
fix/switch
| Author | SHA1 | Date | |
|---|---|---|---|
| cf5e365c7a | |||
| d75af27267 | |||
| c1df8c2162 | |||
| 4b688e4ca0 | |||
| 5db73726f7 | |||
| 64d4462412 | |||
| 34c5b60656 | |||
| 2a6f640ebb | |||
| e779f43d79 | |||
| 6815269372 | |||
| d8a15b8389 | |||
| 72ca231931 | |||
| b9c7694b21 | |||
| 2a0ff2fe41 | |||
| 2ca68c2c80 | |||
| 9db6951434 | |||
| 3d5bf56d09 | |||
| 9588ffc81e | |||
| df01e0dfbd | |||
| 6a26e062d0 | |||
| ab573ba02c | |||
| e109d5629f | |||
| d5763233ca | |||
| 4efdbf7253 | |||
| 261cac950b | |||
| 27f9172750 | |||
| cbe65ddcd7 | |||
| b23f7b0953 | |||
| 196c59f34c | |||
| d7ee801fe4 | |||
| 3760a03c44 | |||
| 7e216109dc | |||
| 229f01513c | |||
| 289f01970f | |||
| 0c716bf974 | |||
| 7807e3ac03 | |||
| f4af702492 | |||
| 4255dd24da | |||
| 2dd86bbc2b | |||
| 09810f554f | |||
| e5c8176ee6 | |||
| 25568c101c | |||
| 590c23d539 | |||
| b5424f7af4 | |||
| 25fd8466e0 | |||
| af6c5a1f45 | |||
| 652f432785 | |||
| d1e805dfc8 | |||
| 42061edbd1 | |||
| 9dfd1ec2dd | |||
| b3f33c1101 | |||
| 5bc4405b23 | |||
| a578d3449b | |||
| 8490cab9b1 | |||
| 9da493902c | |||
| 193ca910f8 | |||
| f42a09be0d | |||
| 4e0f3e79c5 | |||
| 9d77baccfd | |||
| 9cf3469b81 | |||
| bc2556d004 | |||
| 342bec5211 | |||
| 156b47b1b5 | |||
| aa09dc39fb | |||
| d643c9453e | |||
| a4e9251eb7 | |||
| 1bb57bf15a | |||
| c5a11fe17b | |||
| c7df74fcc4 | |||
| ae0644177b | |||
| 4cc7af7874 | |||
| 0bee0bbade | |||
| b14867c67e | |||
| c10236f7a9 | |||
| 735aaa668a | |||
| 516e2f1b6e | |||
| 2d1c9c1a93 | |||
| cc19e45caf | |||
| 187f036c80 | |||
| fac86a2a28 | |||
| 26c73116b0 | |||
| eafb9c5225 | |||
| 76d83b5e82 | |||
| 1cd770e38d | |||
| e7867e52e0 | |||
| 0203b2bc17 | |||
| bcd6e17781 | |||
| 051d58011e | |||
| 6d987975bc | |||
| 16277d1074 | |||
| 76e98c31e3 | |||
| afb0dad0a5 | |||
| af8e74c373 | |||
| 9d82bf2a69 | |||
| dc29fb475d | |||
| 175857753e | |||
| 13b8ed58fd | |||
| bc40a0074a | |||
| 36ee42084c | |||
| c8a67c374f | |||
| cd3393585c | |||
| 797e5b63e7 | |||
| 39268f471e | |||
| b52b1b02fe | |||
| 390a1343b7 | |||
| 37a29934ec | |||
| e8c88b3c33 | |||
| 80c6e57aa3 | |||
| e4dbd67ae1 | |||
| 50547ae355 | |||
| 88fcf8cb44 | |||
| 0d66055441 | |||
| f4619e0e77 | |||
| 79e2cad5b9 | |||
| 49533d7a3f | |||
| 8c6ab3db37 | |||
| c2c7a7190c | |||
| 703708e5d2 | |||
| 090e5f2849 | |||
| 062016b164 | |||
| 266f8c96c4 | |||
| 8bced69828 | |||
| d0e7f2de65 | |||
| 5abc73fabc | |||
| 49718f2894 | |||
| bb29dcaccc | |||
| 19704e15ba | |||
| 7fa2644caf | |||
| 71820fb3ad | |||
| 2ad32e5009 | |||
| 7c5514ae10 | |||
| ce27332ccb | |||
| 86e171442d | |||
| fc564cfc1a | |||
| c6cc04cad8 | |||
| 8d05c752ea | |||
| b20a341f0c | |||
| d3cbf250e6 | |||
| ece879b2cc | |||
| 3cee22da89 | |||
| 917167cf1b | |||
| a01dded55a | |||
| 09df6fa0d1 | |||
| 6cdf255074 | |||
| eb0c324da8 | |||
| 5be2eb75f3 | |||
| ce614ca170 | |||
| 448694a561 | |||
| 4998421cc0 | |||
| 36c0372d62 | |||
| 66d5a567c2 | |||
| 73bc7d9f2a | |||
| 5507295a36 | |||
| 6fc1b5cc22 | |||
| 81f3347ecf | |||
| 791be77867 | |||
| 80b2ba886a | |||
| 0a89b7b5b9 | |||
| 39018f16b5 | |||
| f8736758f1 | |||
| e7c3028ef2 | |||
| 8b73f98b9a | |||
| 93efedb912 | |||
| d4695f3b5b | |||
| 9a7b5ea1f4 | |||
| 4266b2eb3c | |||
| 88ce043438 | |||
| e2ee6e16d7 | |||
| 32126602fd | |||
| 0079ac90e7 | |||
| 1e3f3ff047 | |||
| 632799fea6 | |||
| 0a7723a10f | |||
| 6b1a1750c6 | |||
| 2f4531ff49 | |||
| 74615bfe37 | |||
| 1a3badbb8a | |||
| 6f73cb4939 | |||
| e1903c4016 | |||
| 0340149457 | |||
| 46f839431b | |||
| 60223f4404 | |||
| 19e85c63c2 | |||
| ca0a900c21 | |||
| 5ebfdf7b8a | |||
| ff662acfe0 | |||
| 7aa4709e69 | |||
| e5b4186faa | |||
| 7c6ce44fe1 | |||
| 6713dfcb89 | |||
| 724f641d44 | |||
| 0a40b3a7e2 | |||
| 11e14bc4f5 | |||
| 05b9ae3e63 | |||
| 79d27e9684 | |||
| cda43b5c7f | |||
| 7f8db01168 | |||
| 29b75f84c2 | |||
| 355013a160 | |||
| 09afe7137d | |||
| 8b4bd29515 | |||
| ac5d47e35b | |||
| 6550d89d9b | |||
| b16e27a790 | |||
| f87ac36d57 | |||
| 986ccdf355 | |||
| 891671a45b | |||
| 6e371f0d03 | |||
| ce2a58e83b | |||
| d9cb40ae84 | |||
| 2db49820f9 | |||
| e4f0ef6ddf | |||
| 7c37724f88 | |||
| 9eb72a3485 | |||
| 332ab4cd40 | |||
| 8085c16d32 | |||
| 10da62a4e8 | |||
| 11c714e47d | |||
| 82bc6ef45b | |||
| d8d3e2bef2 | |||
| e8aed2e6c9 | |||
| 77d24dd7f8 | |||
| d13254b543 | |||
| e2841aebcd | |||
| 4545b625fe | |||
| 98daf62a5c | |||
| 15b1069bcc | |||
| 25ccfb5d04 | |||
| 5b27c0f84d | |||
| c64387668d | |||
| ec5e3120d2 | |||
| 8a76dd49a0 | |||
| 33524a1322 | |||
| b616292fce | |||
| 94a2e453eb | |||
| a674a8668b | |||
| 28ab3402ac | |||
| 8a9c09c646 | |||
| 30a80fa92d | |||
| e899616081 | |||
| 411a76f9ff | |||
| 87125223de | |||
| 6a64dfe4b2 | |||
| 87e8ef8fe6 | |||
| c597f2d9a2 | |||
| 37be7f99c4 | |||
| 9865eab61c | |||
| a600a897c3 | |||
| e170a63094 | |||
| c30908e858 | |||
| 3ac7a839ad | |||
| 7bc7d7cd6b | |||
| dcec3dd4ec | |||
| b2a1e6b309 | |||
| 5dd5df6268 | |||
| 8cdb78c94a | |||
| 398c7eb25c | |||
| ee270abbe9 | |||
| 70ec8c551e | |||
| 3a29e157b2 | |||
| ed8334d77a | |||
| 6df8e9e13f | |||
| eb9ff990ac | |||
| 1a6921f6c7 | |||
| 89115a53ca | |||
| ad3bb98087 | |||
| a839f2ed5b | |||
| ea12ebea0e | |||
| 57fa1305a6 | |||
| 5f00e1394d | |||
| b6a058147e | |||
| 2c6ec11708 | |||
| 5900176b58 | |||
| e2c80e5a28 | |||
| 61d56922d5 | |||
| 9a1c5c187e | |||
| f2b007bcf5 | |||
| 578d4c4517 | |||
| 313fd0c1b4 | |||
| 1641423106 | |||
| 67f06895e7 | |||
| 030419fa80 | |||
| 3987de1d7e | |||
| 12b9e750e9 | |||
| 1819920c04 | |||
| a33bac66e4 | |||
| 043a7b41b5 | |||
| 4266a95569 | |||
| 1d6bae05e6 | |||
| bb36425175 | |||
| adca894e83 | |||
| 2e56bdc388 | |||
| 7f3df8158a | |||
| 1b539dcf83 | |||
| 1d22b7ae0c | |||
| b1f04145e5 | |||
| 2a32a4095d | |||
| 33b6fbdee9 | |||
| dc92e1ead4 | |||
| 248c8b3c01 | |||
| d9f8d2e6d4 | |||
| 99684d0900 | |||
| 6c2d43075c | |||
| b44c15c6eb | |||
| f7b587b5a5 | |||
| c79a22b857 | |||
| 63c0daf483 | |||
| 51971d2c9a | |||
| d443abfc57 | |||
| 3610a7f4a2 | |||
| 90fdf5106a | |||
| 77fb76ff0c | |||
| 583353e904 | |||
| 26e2ae4bf1 | |||
| 4f34b69cfa | |||
| 8c1979465f | |||
| cc7a50fbb1 | |||
| 5299ada3c9 | |||
| 96fc95a9f3 | |||
| 2219cfd83e | |||
| 6a1566c275 | |||
| b9346fe6ea | |||
| 18572cf9de | |||
| 59f4b3bd97 | |||
| bb9d7d310b | |||
| 3a19c02e34 | |||
| a6b3dbc894 | |||
| e0405cca0e | |||
| 09a409b494 | |||
| ccd947c6e8 | |||
| 4e05728218 | |||
| 40e09dd829 | |||
| 99381d10ff | |||
| 36c73051b4 | |||
| 81718c8ee1 | |||
| be905a6993 | |||
| b39d4aade7 | |||
| c5fb5f875f | |||
| 552755dace | |||
| e61c71766f | |||
| df5dc2f691 | |||
| 28097835d0 | |||
| 3de51c1a67 | |||
| 223a47af95 | |||
| 7c8675ce17 | |||
| 157c3ce80f | |||
| 0ed7286fc6 | |||
| 78464f315c | |||
| 79790de9b0 | |||
| 252459f1cf | |||
| 20a72481dc | |||
| 765c7cdc27 | |||
| 6f136e342f | |||
| 9545113d9e | |||
| c00001086a | |||
| 95dbc8168c | |||
| 0021553518 | |||
| bcca4b91ee | |||
| c1bd30aac8 | |||
| fd7dd83a4b | |||
| 26f02cdd05 | |||
| fec2baf361 | |||
| e1601fbe72 | |||
| a88b54d26d | |||
| 88cc964d69 | |||
| b8efe772fe | |||
| b2f00d71d3 | |||
| c2edfca6e5 | |||
| 9c3c0fe418 | |||
| 313067ff7b | |||
| be64c2b206 | |||
| d576ce1734 | |||
| 0f624958bc | |||
| 162da9a3ad | |||
| d7e9ad4f13 | |||
| bcf773a1d6 | |||
| 97082e8cba | |||
| bc3f2e4876 | |||
| 49a9b91708 | |||
| 01cea549a5 | |||
| a9df3f64cf | |||
| e6cc8f5550 | |||
| f6c2a95a55 | |||
| 27736f66ef | |||
| cde2909296 | |||
| 1f6e1a71f9 | |||
| 15ef8f7dff | |||
| 83a61b87ed | |||
| 6c605cf720 | |||
| fb335887cb | |||
| 88e7d4c539 | |||
| 400e32da70 | |||
| a699dea286 | |||
| 2aca760ee0 | |||
| f1c9c6fdf9 | |||
| 801f6681ba | |||
| 0a998789a3 | |||
| 92016bbd06 | |||
| 231ab2da03 | |||
| bd880ee984 | |||
| 995c6f90b7 | |||
| 8ac853bb8b | |||
| 2f5cf90cb7 | |||
| c709e54738 | |||
| 47953b3354 | |||
| d96099b5b8 | |||
| 4b2bf28531 | |||
| a3df9e868f | |||
| 476b5e03f9 | |||
| 23a6459ae8 | |||
| 4929fbaccb | |||
| 08a8fea69a | |||
| 2024c6e64f | |||
| 3dfd336f59 | |||
| 9a875920ac | |||
| f389ac6414 | |||
| e4b7aa6761 | |||
| 00ba65f3ef | |||
| 28aef82af9 | |||
| 86f008293a | |||
| 835fd26a95 | |||
| cc9468e2c5 | |||
| 22ba4d0f48 | |||
| d335670b91 | |||
| cabaee2d0a | |||
| f6d889f759 | |||
| a50471959b | |||
| d8ad2fc1a2 | |||
| 0c48227b57 | |||
| 72da0653cc | |||
| e613ec732b | |||
| 0be40609ed | |||
| ec8fde0a5f | |||
| 2c52a8cb8b | |||
| 1db31eed41 | |||
| 8ba8013c6a | |||
| 1521d4dbac | |||
| a1a4fd1baf | |||
| 31f4424018 | |||
| 1f5b83aaeb | |||
| 77db0c2e95 | |||
| 4cbae1cf7d | |||
| e985078b80 | |||
| 09b73401de | |||
| 42b384688d | |||
| 5bdee1204e | |||
| 9db72217af | |||
| 57a2524fbd | |||
| bd148f4790 | |||
| 28d32af613 | |||
| f2f550e1d2 | |||
| dad21b2186 | |||
| 5fb5f1e8b5 | |||
| 2d0690697c | |||
| 6b551749d4 | |||
| 52fc861bcf | |||
| c81c9a9d2d | |||
| 29c742a673 | |||
| dd249021e7 | |||
| 21d3b9c7e0 | |||
| 6665dfff28 | |||
| cdfe3a7fc3 | |||
| 401c91f90b | |||
| ed5320507d | |||
| e34581d25f | |||
| 65a1e2630c | |||
| 59de4a7db0 | |||
| 63eb8aadaf | |||
| 37fd7ec97a | |||
| 928106067f | |||
| cb7c27690f | |||
| 26da8c4165 | |||
| 36b8ae859e | |||
| ad1eaa5210 | |||
| 98024f6be1 | |||
| 37c02a572b | |||
| e53bb8bfbc | |||
| 2a473bf7b4 | |||
| f3b09ab56a | |||
| 6eb51a9cb9 | |||
| d01c40badb | |||
| fc551c91bd | |||
| fdc1955b91 | |||
| b6703671e2 | |||
| 84f647674a | |||
| a81fbd8608 | |||
| 8ee018a759 | |||
| 6815c940b2 | |||
| c9bd3bbf45 | |||
| f61f9703f3 | |||
| 48d538b424 | |||
| 84ad7c482c | |||
| d35b5d2613 | |||
| 3090c2cfa3 | |||
| 140b04c126 | |||
| 2aedf4440b | |||
| 6e07ee3f3e | |||
| bba8cd183b | |||
| 0bc609634c | |||
| b3b8cb3d9c | |||
| fdb85ec195 | |||
| f64ab37d3c | |||
| 0b3adad751 | |||
| 83477de300 | |||
| 1726006858 | |||
| 3d9eaeeeeb | |||
| 2e955353ae | |||
| 05aba68457 | |||
| 8f6e956bc5 | |||
| 0cad99c343 | |||
| 04746f6a2c | |||
| 25907f5c72 | |||
| d7a21db72f | |||
| 9596979993 | |||
| 31714efb0b | |||
| 8884da8a4b | |||
| 30cf244610 | |||
| 3f030540b3 | |||
| 7ae3addea0 | |||
| a9d758bb0c | |||
| 06e16eef12 | |||
| 8e5a2b85c2 | |||
| 5689d96cc4 | |||
| 5cd4dbd9d7 | |||
| 587a0e0517 | |||
| 686ecdfa92 | |||
| bb019b081f | |||
| 7d5fbeb7b0 | |||
| 056f89fcfd | |||
| 0e7d352781 | |||
| b5e4e4fe82 | |||
| e41f17c701 | |||
| 9a1c8f07d1 | |||
| 241cb11493 | |||
| 8195791bb2 | |||
| b037ae5dc1 | |||
| aeba8ce4eb | |||
| 429c5fba85 | |||
| 9495ddba25 | |||
| 486a60e97c | |||
| c687745263 | |||
| 1b92993b90 | |||
| 181a20a268 | |||
| f8ffa4e25a | |||
| 7e139ca8f7 | |||
| bb58db507d | |||
| 49bf86d6d9 | |||
| 286a15cf10 | |||
| f65469b777 | |||
| fe65a79d66 | |||
| a1d5ac0907 | |||
| 04eabe68a7 | |||
| 1c0c694c22 | |||
| 2ae74f2834 | |||
| 0f01fc5faa | |||
| 7f1322b7ba | |||
| 3c98133e24 | |||
| 088353d61f | |||
| 31180619e1 | |||
| 9fccc280d7 | |||
| c69b4efc34 | |||
| 3cec6b4903 | |||
| ede7f2e3e6 | |||
| cf8fa5ffa3 | |||
| 1a2a0f4264 | |||
| 5f3a38bf87 | |||
| afff3a6f25 | |||
| b5824879a3 | |||
| 1c82e292e0 | |||
| 317289ac2a | |||
| 8331026cb3 | |||
| de285f2b63 | |||
| d205c48296 | |||
| 277c37dae6 | |||
| 2c39cd6496 | |||
| d85592b5f3 | |||
| cdf0df0faa | |||
| 48f54b5aa2 | |||
| 2ca57fc7cf | |||
| 470920e2c3 | |||
| beee8ebee7 | |||
| 9f05c9bd43 | |||
| 65be808556 | |||
| 89f8df619c | |||
| 756ec92cdb | |||
| a8e2e349e9 | |||
| 25f745e7e5 | |||
| 07b1811993 | |||
| d71f0ae6bd | |||
| f58032d305 | |||
| 6beb6febc4 | |||
| a6d4d4ea36 | |||
| a99f6bed42 | |||
| 4cd61db1ea | |||
| 0db7bb7f3e | |||
| d8ca9c6111 | |||
| 4a8d357084 | |||
| e0fb76cb63 | |||
| ffed38bf71 | |||
| b4c08a027b | |||
| 74e0f4dfb3 | |||
| 5c7f2cf164 | |||
| f517a2cecb | |||
| a19ac6aa5f | |||
| ddbbb963b6 | |||
| ba24a3318e | |||
| 7a6491cf0d | |||
| 0c8d4428fc | |||
| b19fd799ef | |||
| 082ced3072 | |||
| 1f49b35c89 | |||
| 9817e2f3bf | |||
| 04d7c7ac0e | |||
| e625e77a56 | |||
| 636023aceb | |||
| f2dfed4c72 | |||
| 8cfa724200 | |||
| 6c011eb9b5 | |||
| 7dc11e5b86 | |||
| 44920a25f4 | |||
| dc4b5588b7 | |||
| 635910195b | |||
| eaf2e50af8 | |||
| 505ed3403a | |||
| b93d15e967 | |||
| 028eb72f9c | |||
| b0196f0cf0 | |||
| 833bd51f4c | |||
| 14d9adefe7 | |||
| ec3ea09b2d | |||
| 2c0f14f07b | |||
| a93d034091 | |||
| 447371f35a | |||
| 3bd56fff9e | |||
| 9d03c89c02 | |||
| 9f226cf3b4 | |||
| d01e3f7c72 | |||
| 2cb0bab82a | |||
| 456a7e497b | |||
| a39f7a4e55 | |||
| fed3774cee | |||
| 985f0da674 | |||
| 721e7466e6 | |||
| 8e1d9f0a7d | |||
| 71de0c7e5f | |||
| 4f4067c449 | |||
| b945b614f8 | |||
| 896ee5c20d | |||
| e984a3dcdb | |||
| 69802cc985 | |||
| 6ef8d9ddb3 | |||
| d21594a6f4 | |||
| 974d6b2cbe | |||
| aa3cb22703 | |||
| 49ffcda8e0 | |||
| 77d6adb73b | |||
| 4d68a34897 | |||
| 61b2e63a44 | |||
| ae940dd255 | |||
| b13626631c | |||
| 7221e51b96 | |||
| b89f4c36f4 | |||
| 829cc14d36 | |||
| 8009e8f691 | |||
| ab2aaf7b7b | |||
| 65b4480e93 | |||
| 6de793e94e | |||
| 2d22399bbc | |||
| 3fbb3a2403 | |||
| d45178cb44 | |||
| 5786a03f33 | |||
| 011a1383ec | |||
| 72d7b5734d | |||
| b6fe3cb556 | |||
| 1e2224cb0d | |||
| 0477060b35 | |||
| a261abcdef | |||
| f64d0ce660 | |||
| f27072d06e | |||
| c8055e40bb | |||
| cfae180093 | |||
| 094c6418c9 | |||
| 99b1bf0ecb | |||
| 3b696cfa9a | |||
| eb6acdae20 | |||
| a818c7a924 | |||
| d157e9bfcd | |||
| f2052c2a05 | |||
| 40b4270e35 | |||
| 16c60a0d59 | |||
| 1a183ba0fc | |||
| 2ffc0ae81c | |||
| 50fdd73610 | |||
| a134773d4e | |||
| 317c52df62 | |||
| 04b8d7ae7b | |||
| 3569d2fee7 | |||
| ab267ce38d | |||
| fa52bc5afd | |||
| bf668d6347 | |||
| 7f9cba9819 | |||
| e9f083feb8 | |||
| 03d90b3f15 | |||
| 2432b4dcbd | |||
| 2c2c1341f7 | |||
| 7a8ccdb229 | |||
| b2d703bee4 | |||
| c46a032f0b | |||
| 940ad8479e | |||
| c5401a467d | |||
| 1dd97c1ddd | |||
| f37371c16e | |||
| 62f9262b2c | |||
| bc4fe05147 | |||
| cdc7f61fa1 | |||
| 2a6dfdea5d | |||
| de25ea0ed9 | |||
| d2227a2488 | |||
| 3e050727cb | |||
| 326518873e | |||
| ed779a250f | |||
| 190f0b6dc5 | |||
| 1a889e9913 | |||
| b3203857d7 | |||
| 5762fb33d9 | |||
| 1101ea428b | |||
| b4213e498c | |||
| f9f76d4438 | |||
| 4a9571a174 | |||
| bf856dbafa | |||
| 0e54302d75 | |||
| 4777176d84 | |||
| 3ffa21b07f | |||
| 8cbc873451 | |||
| d2e8311b39 | |||
| 810257bcf5 | |||
| 2ef0caba88 | |||
| 2e64972574 | |||
| 7e1b07ef98 | |||
| d7acf616cf | |||
| c5569bd365 | |||
| 25023fb086 | |||
| 07425f4243 | |||
| b5dcb1b3fe | |||
| ae57cdea2a | |||
| 8599b60a6c | |||
| e00a437f2f | |||
| 626c94ecea | |||
| 889186e510 | |||
| 4166257283 | |||
| 6a7d7af767 | |||
| 46912f8ddb | |||
| 877c01f723 | |||
| 97158b1337 | |||
| 8d8bde4b8b | |||
| 059fca27b3 | |||
| 9f6ba798c8 | |||
| 349e971a8a | |||
| 9af9d3a008 | |||
| bb5443452b | |||
| b3353f20d5 | |||
| 200f25c4b2 | |||
| f1296cc8e3 | |||
| ad8c08497c | |||
| 7891a8ee8b | |||
| 56c4acc18f | |||
| 1b972070d7 | |||
| 138336639d | |||
| 8ea746dbe8 | |||
| 46bcc2e2ae | |||
| b93002ad93 | |||
| a427d77076 | |||
| eff56b758c | |||
| ffc270b567 | |||
| d86b7babb9 | |||
| ec57951087 | |||
| 2385f41a98 | |||
| bdb684a4be | |||
| 5d6f68d399 | |||
| dc967be4fc | |||
| d530edcc2f | |||
| 1393d1950e | |||
| 0aa72036d7 | |||
| f50b88716b | |||
| e90c02bec7 | |||
| 504b11576a | |||
| bac7a364d0 | |||
| ed2a42ac27 | |||
| ab7b16bbb9 | |||
| d8eefc1972 | |||
| b188a8ff30 | |||
| e1c7b07af9 | |||
| eaadeb26e5 | |||
| 0c301fcf0c | |||
| a3e95023dc | |||
| e08b17561e | |||
| ac79a4c4cc | |||
| e085553306 | |||
| 38bd1d5585 | |||
| cd7cbab5ac | |||
| 2195787e7d | |||
| 04f942141f | |||
| d0f1fd533a | |||
| a1e885f057 | |||
| 2ad9f69f7f | |||
| 65bca35bbf | |||
| a96993fda9 | |||
| 9fc03b6ece | |||
| 100360adb3 | |||
| d277d80323 | |||
| c79cfbd30d | |||
| e66611e771 | |||
| 903e83a618 | |||
| 4ef4ef963a | |||
| 51c6a19dc3 | |||
| bbf434e2f4 | |||
| 5b7018058d | |||
| fae54c7957 | |||
| fabfa6a491 | |||
| c5f9412ac0 | |||
| f4c871bb62 | |||
| df233c95a9 | |||
| 568e271738 | |||
| 9efed11a3e | |||
| c30132e558 | |||
| b152a5595e | |||
| 887e341e48 | |||
| ae2f1b47e7 | |||
| 86d9a14c5c | |||
| 6a8a83610f | |||
| 54bf7a9dea | |||
| 43ed7d0343 | |||
| a81a18b173 | |||
| f18a2a048d | |||
| 7e922d8716 | |||
| 4b603460cb | |||
| 32a298054d | |||
| ca2459361e | |||
| e49f3ab9fb | |||
| e9338df057 | |||
| 2629d6db23 | |||
| b017590033 | |||
| 7d244dfa1f | |||
| 2a225d81d2 | |||
| 41df5c74be | |||
| ef026b34fa | |||
| 1dbcc12648 | |||
| 2611376b21 | |||
| a1b3cfc7de | |||
| 5a478ec127 | |||
| c0325fcaf3 | |||
| df472ac391 | |||
| 097359bf7c | |||
| 3739bb7c55 | |||
| cc90c8de1c | |||
| ac6c48817c | |||
| 8e3534dcbc | |||
| cada91a135 | |||
| e2d7d34f30 | |||
| 0d88a1dfda | |||
| df5a2e45c5 | |||
| 1a7a48674b | |||
| e23474fa1c | |||
| 37fa13d841 | |||
| 6d88c02869 | |||
| a2fb3bb9f8 | |||
| 41be18e938 | |||
| caee7afde2 | |||
| d79933887d | |||
| 2787e56de3 | |||
| b932457fd3 | |||
| ea5d2ea9e0 | |||
| 6e9b4e8363 | |||
| 012e6b320e | |||
| c8cd7fcf4a | |||
| 7021c2a9e5 | |||
| 799e639439 | |||
| ba2552f69f | |||
| a51af98d43 | |||
| ad7400a4f5 | |||
| 087ccdd825 | |||
| 938f6ba8c5 | |||
| 7f5a7d7df7 | |||
| b98e4bb1ff | |||
| 5012104a10 |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-flow",
|
||||
"@babel/preset-typescript",
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"corejs": {
|
||||
"version": "2",
|
||||
"version": "3",
|
||||
"proposals": true
|
||||
},
|
||||
"useBuiltIns": "usage"
|
||||
@@ -25,5 +25,17 @@
|
||||
"@babel/plugin-transform-destructuring",
|
||||
"@babel/plugin-transform-regenerator",
|
||||
"transform-class-properties"
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"production": {
|
||||
"plugins": [
|
||||
[
|
||||
"styled-components",
|
||||
{
|
||||
"displayName": false
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
-4
@@ -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
|
||||
@@ -33,11 +41,95 @@ jobs:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: flow
|
||||
command: yarn flow check --max-workers 4
|
||||
name: typescript
|
||||
command: yarn tsc
|
||||
- run:
|
||||
name: test
|
||||
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: /.*/
|
||||
|
||||
+1
-3
@@ -6,14 +6,12 @@ __mocks__
|
||||
.DS_Store
|
||||
.env*
|
||||
.eslint*
|
||||
.flowconfig
|
||||
.log
|
||||
Makefile
|
||||
Procfile
|
||||
app.json
|
||||
crowdin.yml
|
||||
build
|
||||
docker-compose.yml
|
||||
fakes3
|
||||
flow-typed
|
||||
node_modules
|
||||
setupJest.js
|
||||
+131
-44
@@ -1,54 +1,38 @@
|
||||
# Copy this file to .env, remove this comment and change the keys. For development
|
||||
# with docker this should mostly work out of the box other than setting the Slack
|
||||
# keys (for auth) and the SECRET_KEY.
|
||||
#
|
||||
# Please use `openssl rand -hex 32` to create SECRET_KEY
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
|
||||
# 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
|
||||
# `openssl rand -hex 32` in your terminal to produce this.
|
||||
UTILS_SECRET=generate_a_new_key
|
||||
|
||||
# For production point these at your databases, in development the default
|
||||
# should work out of the box.
|
||||
DATABASE_URL=postgres://user:pass@localhost:5532/outline
|
||||
DATABASE_URL_TEST=postgres://user:pass@localhost:5532/outline-test
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
REDIS_URL=redis://localhost:6479
|
||||
|
||||
# Must point to the publicly accessible URL for the installation
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# Optional. If using a Cloudfront distribution or similar the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
# See [documentation](docs/SERVICES.md) on running a separate collaboration
|
||||
# server, for normal operation this does not need to be set.
|
||||
COLLABORATION_URL=
|
||||
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
# To support uploading of images for avatars and document attachments an
|
||||
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
|
||||
# however if you want to keep all file storage local an alternative such as
|
||||
# minio (https://github.com/minio/minio) can be used.
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
# A more detailed guide on setting up S3 is available here:
|
||||
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
|
||||
#
|
||||
# When configuring the Client ID, add an Authorized redirect URI:
|
||||
# https://<your Outline URL>/auth/google.callback
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Comma separated list of domains to be allowed (optional)
|
||||
# If not set, all Google apps domains are allowed by default
|
||||
GOOGLE_ALLOWED_DOMAINS=
|
||||
|
||||
# Third party credentials (optional)
|
||||
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
SENTRY_DSN=
|
||||
|
||||
# AWS credentials (optional in development)
|
||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
@@ -56,21 +40,124 @@ AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
# uploaded s3 objects permission level, default is private
|
||||
# set to "public-read" to allow public access
|
||||
AWS_S3_ACL=private
|
||||
|
||||
# Emails configuration (optional)
|
||||
|
||||
# –––––––––––––– AUTHENTICATION ––––––––––––––
|
||||
|
||||
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
||||
# or Microsoft is required for a working installation or you'll have no sign-in
|
||||
# options.
|
||||
|
||||
# To configure Slack auth, you'll need to create an Application at
|
||||
# => https://api.slack.com/apps
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
|
||||
# https://<URL>/auth/slack.callback
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
#
|
||||
# When configuring the Client ID, add an Authorized redirect URI:
|
||||
# https://<URL>/auth/google.callback
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# 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_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
|
||||
|
||||
# Space separated auth scopes.
|
||||
OIDC_SCOPES="openid profile email"
|
||||
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
# required if you do not use an external reverse proxy. See documentation:
|
||||
# https://wiki.generaloutline.com/share/1c922644-40d8-41fe-98f9-df2b67239d45
|
||||
SSL_KEY=
|
||||
SSL_CERT=
|
||||
|
||||
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
|
||||
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# Auto-redirect to https in production. The default is true but you may set to
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# Have the installation check for updates by sending anonymized statistics to
|
||||
# the maintainers
|
||||
ENABLE_UPDATES=true
|
||||
|
||||
# How many processes should be spawned. As a reasonable rule divide your servers
|
||||
# available memory by 512 for a rough estimate
|
||||
WEB_CONCURRENCY=1
|
||||
|
||||
# Override the maxium size of document imports, could be required if you have
|
||||
# especially large Word documents with embedded imagery
|
||||
MAXIMUM_IMPORT_SIZE=5120000
|
||||
|
||||
# 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
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
#
|
||||
SLACK_VERIFICATION_TOKEN=your_token
|
||||
SLACK_APP_ID=A0XXXXXXX
|
||||
SLACK_MESSAGE_ACTIONS=true
|
||||
|
||||
# Optionally enable google analytics to track pageviews in the knowledge base
|
||||
GOOGLE_ANALYTICS_ID=
|
||||
|
||||
# Optionally enable Sentry (sentry.io) to track errors and performance
|
||||
SENTRY_DSN=
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
# See translate.getoutline.com for a list of available language codes and their
|
||||
# percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
@@ -1,20 +1,46 @@
|
||||
{
|
||||
"parser": "babel-eslint",
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"extraFileExtensions": [".json"],
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"extends": [
|
||||
"react-app",
|
||||
"plugin:import/errors",
|
||||
"plugin:import/warnings",
|
||||
"plugin:flowtype/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"prettier",
|
||||
"flowtype"
|
||||
"@typescript-eslint",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"eslint-plugin-react-hooks",
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"no-unused-vars": 2,
|
||||
"curly": 2,
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
@@ -23,53 +49,48 @@
|
||||
},
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "shared/**",
|
||||
"pattern": "@shared/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "stores",
|
||||
"pattern": "@server/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "stores/**",
|
||||
"pattern": "~/stores",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "models/**",
|
||||
"pattern": "~/stores/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "scenes/**",
|
||||
"pattern": "~/models/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "components/**",
|
||||
"pattern": "~/scenes/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/components/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"flowtype/require-valid-file-annotation": [
|
||||
2,
|
||||
"always",
|
||||
{
|
||||
"annotationStyle": "line"
|
||||
}
|
||||
],
|
||||
"flowtype/space-after-type-colon": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"flowtype/space-before-type-colon": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
@@ -84,22 +105,11 @@
|
||||
"pragma": "React",
|
||||
"version": "detect"
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"paths": [
|
||||
"app",
|
||||
"."
|
||||
]
|
||||
}
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx"]
|
||||
},
|
||||
"flowtype": {
|
||||
"onlyFilesWithFlowAnnotation": false
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"globals": {
|
||||
"EDITOR_VERSION": true
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
[include]
|
||||
.*/app/.*
|
||||
.*/server/.*
|
||||
.*/shared/.*
|
||||
|
||||
[ignore]
|
||||
.*/node_modules/tiny-cookie/flow/.*
|
||||
.*/node_modules/styled-components/.*
|
||||
.*/node_modules/polished/.*
|
||||
.*/node_modules/mobx/.*.flow
|
||||
.*/node_modules/react-side-effect/.*
|
||||
.*/node_modules/fbjs/.*
|
||||
.*/node_modules/config-chain/.*
|
||||
.*/server/scripts/.*
|
||||
*.test.js
|
||||
|
||||
[libs]
|
||||
|
||||
[options]
|
||||
emoji=true
|
||||
sharedmemory.heap_size=3221225472
|
||||
|
||||
module.system.node.resolve_dirname=node_modules
|
||||
module.system.node.resolve_dirname=app
|
||||
|
||||
module.name_mapper='^\(.*\)\.md$' -> 'empty/object'
|
||||
module.name_mapper='^shared\/\(.*\)$' -> '<PROJECT_ROOT>/shared/\1'
|
||||
|
||||
module.file_ext=.js
|
||||
module.file_ext=.md
|
||||
module.file_ext=.json
|
||||
|
||||
esproposal.decorators=ignore
|
||||
esproposal.class_static_fields=enable
|
||||
esproposal.class_instance_fields=enable
|
||||
esproposal.optional_chaining=enable
|
||||
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [outline]
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
daysUntilStale: 120
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
daysUntilClose: 14
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- security
|
||||
- pinned
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '28 15 * * 2'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
+3
-1
@@ -1,7 +1,6 @@
|
||||
dist
|
||||
build
|
||||
node_modules/*
|
||||
server/scripts
|
||||
.env
|
||||
.log
|
||||
npm-debug.log
|
||||
@@ -9,3 +8,6 @@ stats.json
|
||||
.DS_Store
|
||||
fakes3/*
|
||||
.idea
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
|
||||
Vendored
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.validate.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
"javascript.validate.enable": true,
|
||||
"javascript.format.enable": true,
|
||||
"typescript.validate.enable": true,
|
||||
"typescript.format.enable": true,
|
||||
"editor.formatOnSave": true,
|
||||
}
|
||||
+41
-13
@@ -1,23 +1,51 @@
|
||||
FROM node:14-alpine
|
||||
# syntax=docker/dockerfile:1.2
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:16-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:16-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
|
||||
ARG CDN_URL
|
||||
RUN yarn build
|
||||
|
||||
RUN yarn build && \
|
||||
yarn --production --ignore-scripts --prefer-offline && \
|
||||
rm -rf shared && \
|
||||
rm -rf app
|
||||
# ---
|
||||
FROM node:16-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"]
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.51.0
|
||||
Licensed Work: Outline 0.60.1
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2023-12-13
|
||||
Change Date: 2025-11-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
web: node ./build/server/index.js
|
||||
web: yarn start --services=web,websockets,collaboration
|
||||
worker: yarn start --services=worker
|
||||
|
||||
@@ -1,150 +1,57 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>An open, extensible, wiki for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
<br/>
|
||||
<img src="https://user-images.githubusercontent.com/380914/78513257-153ae080-775f-11ea-9b49-1e1939451a3e.png" alt="Outline" width="800" />
|
||||
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
|
||||
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
|
||||
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
|
||||
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
|
||||
</p>
|
||||
|
||||
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com).
|
||||
|
||||
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
|
||||
|
||||
## Installation
|
||||
# Installation
|
||||
|
||||
Outline requires the following dependencies:
|
||||
Please see the [documentation](https://app.getoutline.com/share/770a97da-13e5-401e-9f8a-37949c19f97e/) for running your own copy of Outline in a production configuration.
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 12
|
||||
- [Yarn](https://yarnpkg.com)
|
||||
- [Postgres](https://www.postgresql.org/download/) >=9.5
|
||||
- [Redis](https://redis.io/) >= 4
|
||||
- AWS S3 bucket or compatible API for file storage
|
||||
- Slack or Google developer application for authentication
|
||||
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
|
||||
|
||||
# Development
|
||||
|
||||
### Production
|
||||
There is a short guide for [setting up a development environment](https://app.getoutline.com/share/770a97da-13e5-401e-9f8a-37949c19f97e/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
|
||||
|
||||
For a manual self-hosted production installation these are the suggested steps:
|
||||
## Contributing
|
||||
|
||||
1. Clone this repo and install dependencies with `yarn install`
|
||||
1. Build the source code with `yarn build`
|
||||
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
|
||||
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
|
||||
1. `URL` (the public facing URL of your installation)
|
||||
1. `AWS_` (all of the keys beginning with AWS)
|
||||
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
|
||||
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
|
||||
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
> Port number can be changed using the `PORT` environment variable
|
||||
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of your code being accepted.
|
||||
|
||||
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
- [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
|
||||
|
||||
### Development
|
||||
## Architecture
|
||||
|
||||
In development you can quickly get an environment running using Docker by following these steps:
|
||||
If you're interested in contributing or learning more about the Outline codebase
|
||||
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
|
||||
|
||||
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. 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. Add `http://localhost:3000/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
|
||||
## Debugging
|
||||
|
||||
### Upgrade
|
||||
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.
|
||||
|
||||
#### Docker
|
||||
|
||||
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
|
||||
```
|
||||
docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
|
||||
```
|
||||
#### Yarn
|
||||
|
||||
If you're running Outline by cloning this repository, run the following command to upgrade:
|
||||
```
|
||||
yarn run upgrade
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Server
|
||||
|
||||
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
||||
|
||||
```
|
||||
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
```
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
|
||||
Or to run migrations on test database:
|
||||
|
||||
```
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI.
|
||||
|
||||
### Frontend
|
||||
|
||||
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [Mobx](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
|
||||
|
||||
The editor itself is built on [Prosemirror](https://github.com/prosemirror) and hosted in a separate repository to encourage reuse: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor)
|
||||
|
||||
- `app/` - Frontend React application
|
||||
- `app/scenes` - Full page views
|
||||
- `app/components` - Reusable React components
|
||||
- `app/stores` - Global state stores
|
||||
- `app/models` - State models
|
||||
- `app/types` - Flow types for non-models
|
||||
|
||||
### Backend
|
||||
|
||||
Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails.
|
||||
|
||||
- `server/api` - API endpoints
|
||||
- `server/commands` - Domain logic, currently being refactored from /models
|
||||
- `server/emails` - React rendered email templates
|
||||
- `server/models` - Database models
|
||||
- `server/policies` - Authorization logic
|
||||
- `server/presenters` - API responses for database models
|
||||
- `server/test` - Test helps and support
|
||||
- `server/utils` - Utility methods
|
||||
- `shared` - Code shared between frontend and backend applications
|
||||
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -160,31 +67,35 @@ 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
|
||||
# To run backend tests
|
||||
yarn test:server
|
||||
|
||||
# To run a specific backend test
|
||||
yarn test:server myTestFile
|
||||
|
||||
# To run frontend tests
|
||||
yarn test:app
|
||||
```
|
||||
|
||||
## Contributing
|
||||
## Migrations
|
||||
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
Sequelize is used to create and run migrations, for example:
|
||||
|
||||
However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster!
|
||||
```shell
|
||||
yarn sequelize migration:generate --name my-migration
|
||||
yarn sequelize db:migrate
|
||||
```
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
Or to run migrations on test database:
|
||||
|
||||
* [Translation](TRANSLATION.md) into other languages
|
||||
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
|
||||
* Performance improvements, both on server and frontend
|
||||
* Developer happiness and documentation
|
||||
* Bugs and other issues listed on GitHub
|
||||
```shell
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
|
||||
Outline is [BSL 1.1 licensed](LICENSE).
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
export default class Queue {
|
||||
name;
|
||||
|
||||
|
||||
@@ -30,8 +30,12 @@
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": {
|
||||
"value": "production",
|
||||
"required": true
|
||||
},
|
||||
"SECRET_KEY": {
|
||||
"description": "A secret key",
|
||||
"description": "A 32-character secret key, generate with openssl rand -hex 32",
|
||||
"generator": "secret",
|
||||
"required": true
|
||||
},
|
||||
@@ -39,8 +43,12 @@
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
|
||||
"required": false
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com",
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
},
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
@@ -51,8 +59,51 @@
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
|
||||
"AZURE_CLIENT_ID": {
|
||||
"description": "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",
|
||||
"required": false
|
||||
},
|
||||
"AZURE_CLIENT_SECRET": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"AZURE_RESOURCE_APP_ID": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_CLIENT_ID": {
|
||||
"description": "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",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_CLIENT_SECRET": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_AUTH_URI": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_TOKEN_URI": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_USERINFO_URI": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_USERNAME_CLAIM": {
|
||||
"description": "Specify which claims to derive user information from. Supports any valid JSON path with the JWT payload",
|
||||
"value": "preferred_username",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_DISPLAY_NAME": {
|
||||
"description": "Display name for OIDC authentication",
|
||||
"value": "OpenID",
|
||||
"required": false
|
||||
},
|
||||
"OIDC_SCOPES": {
|
||||
"description": "Space separated auth scopes.",
|
||||
"value": "openid profile email",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_KEY": {
|
||||
@@ -131,6 +182,15 @@
|
||||
"description": "wikireply@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_SECURE": {
|
||||
"value": "true",
|
||||
"description": "Use a secure SMTP connection (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_TLS_CIPHERS": {
|
||||
"description": "Override SMTP cipher configuration (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
@@ -142,6 +202,11 @@
|
||||
"TEAM_LOGO": {
|
||||
"description": "A logo that will be displayed on the signed out home page",
|
||||
"required": false
|
||||
},
|
||||
"DEFAULT_LANGUAGE": {
|
||||
"value": "en_US",
|
||||
"description": "The default interface language. See translate.getoutline.com for a list of available language codes and their rough percentage translated.",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc"
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"testURL": "http://localhost",
|
||||
"verbose": false,
|
||||
"rootDir": "..",
|
||||
"roots": [
|
||||
"<rootDir>/app",
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"moduleDirectories": [
|
||||
"node_modules"
|
||||
],
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./app/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "jsdom"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionNew from "~/scenes/CollectionNew";
|
||||
import DynamicCollectionIcon from "~/components/CollectionIcon";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
|
||||
export const openCollection = createAction({
|
||||
name: ({ t }) => t("Open collection"),
|
||||
section: CollectionSection,
|
||||
shortcut: ["o", "c"],
|
||||
icon: <CollectionIcon />,
|
||||
children: ({ stores }) => {
|
||||
const collections = stores.collections.orderedData;
|
||||
return collections.map((collection) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the collection is renamed
|
||||
id: collection.url,
|
||||
name: collection.name,
|
||||
icon: <DynamicCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
perform: () => history.push(collection.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const createCollection = createAction({
|
||||
name: ({ t }) => t("New collection"),
|
||||
section: CollectionSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create a collection"),
|
||||
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollection = createAction({
|
||||
name: ({ t }) => t("Edit collection"),
|
||||
section: CollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ stores, activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Edit collection"),
|
||||
content: (
|
||||
<CollectionEdit
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
collectionId={activeCollectionId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootCollectionActions = [openCollection, createCollection];
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ToolsIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { DebugSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Delete IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear database",
|
||||
section: DebugSection,
|
||||
perform: async ({ t }) => {
|
||||
await deleteAllDatabases();
|
||||
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||
},
|
||||
});
|
||||
|
||||
export const development = createAction({
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DebugSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB],
|
||||
});
|
||||
|
||||
export const rootDebugActions = [development];
|
||||
@@ -0,0 +1,324 @@
|
||||
import invariant from "invariant";
|
||||
import {
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
StarredIcon,
|
||||
PrintIcon,
|
||||
UnstarredIcon,
|
||||
DocumentIcon,
|
||||
NewDocumentIcon,
|
||||
ShapesIcon,
|
||||
ImportIcon,
|
||||
PinIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import { homePath, newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
section: DocumentSection,
|
||||
shortcut: ["o", "d"],
|
||||
keywords: "go to",
|
||||
icon: <DocumentIcon />,
|
||||
children: ({ stores }) => {
|
||||
const paths = stores.collections.pathsToDocuments;
|
||||
|
||||
return paths
|
||||
.filter((path) => path.type === "document")
|
||||
.map((path) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: path.url,
|
||||
name: path.title,
|
||||
icon: () =>
|
||||
stores.documents.get(path.id)?.isStarred ? <StarredIcon /> : null,
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(path.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ activeCollectionId }) =>
|
||||
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
section: DocumentSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.star();
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarDocument = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
section: DocumentSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.isStarred &&
|
||||
stores.policies.abilities(activeDocumentId).unstar
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.unstar();
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
section: DocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download();
|
||||
},
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
section: DocumentSection,
|
||||
icon: <DuplicateIcon />,
|
||||
keywords: "copy",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
|
||||
perform: async ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
invariant(document, "Document must exist");
|
||||
const duped = await document.duplicate();
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
history.push(duped.url);
|
||||
stores.toasts.showToast(t("Document duplicated"), {
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pin a document to a collection. Pinned documents will be displayed at the top
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocument = createAction({
|
||||
name: ({ t }) => t("Pin to collection"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!stores.policies.abilities(activeDocumentId).pin && !document?.pinned
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, activeCollectionId, t, stores }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pin a document to team home. Pinned documents will be displayed at the top
|
||||
* of the home screen for all team members to see.
|
||||
*/
|
||||
export const pinDocumentToHome = createAction({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
visible: ({ activeDocumentId, currentTeamId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!stores.policies.abilities(activeDocumentId).pinToHome &&
|
||||
!document?.pinnedToHome
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, location, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.pin();
|
||||
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
section: DocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: async () => {
|
||||
window.print();
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
section: DocumentSection,
|
||||
icon: <ImportIcon />,
|
||||
keywords: "upload",
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
return !!stores.policies.abilities(activeDocumentId).createChildDocument;
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return !!stores.policies.abilities(activeCollectionId).update;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
const { documents, toasts } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev: Event) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const document = await documents.import(
|
||||
file,
|
||||
activeDocumentId,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update &&
|
||||
!document?.isTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create template"),
|
||||
content: (
|
||||
<DocumentTemplatize
|
||||
documentId={activeDocumentId}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
createDocument,
|
||||
createTemplate,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
printDocument,
|
||||
pinDocument,
|
||||
pinDocumentToHome,
|
||||
];
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
HomeIcon,
|
||||
SearchIcon,
|
||||
ArchiveIcon,
|
||||
TrashIcon,
|
||||
EditIcon,
|
||||
OpenIcon,
|
||||
SettingsIcon,
|
||||
ShapesIcon,
|
||||
KeyboardIcon,
|
||||
EmailIcon,
|
||||
LogoutIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import {
|
||||
developersUrl,
|
||||
changelogUrl,
|
||||
mailToUrl,
|
||||
githubIssuesUrl,
|
||||
} from "@shared/utils/urlHelpers";
|
||||
import stores from "~/stores";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
settingsPath,
|
||||
homePath,
|
||||
searchUrl,
|
||||
draftsPath,
|
||||
templatesPath,
|
||||
archivePath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const navigateToHome = createAction({
|
||||
name: ({ t }) => t("Home"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["d"],
|
||||
icon: <HomeIcon />,
|
||||
perform: () => history.push(homePath()),
|
||||
visible: ({ location }) => location.pathname !== homePath(),
|
||||
});
|
||||
|
||||
export const navigateToSearch = createAction({
|
||||
name: ({ t }) => t("Search"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["/"],
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchUrl()),
|
||||
visible: ({ location }) => location.pathname !== searchUrl(),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
name: ({ t }) => t("Drafts"),
|
||||
section: NavigationSection,
|
||||
icon: <EditIcon />,
|
||||
perform: () => history.push(draftsPath()),
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
export const navigateToTemplates = createAction({
|
||||
name: ({ t }) => t("Templates"),
|
||||
section: NavigationSection,
|
||||
icon: <ShapesIcon />,
|
||||
perform: () => history.push(templatesPath()),
|
||||
visible: ({ location }) => location.pathname !== templatesPath(),
|
||||
});
|
||||
|
||||
export const navigateToArchive = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
section: NavigationSection,
|
||||
icon: <ArchiveIcon />,
|
||||
perform: () => history.push(archivePath()),
|
||||
visible: ({ location }) => location.pathname !== archivePath(),
|
||||
});
|
||||
|
||||
export const navigateToTrash = createAction({
|
||||
name: ({ t }) => t("Trash"),
|
||||
section: NavigationSection,
|
||||
icon: <TrashIcon />,
|
||||
perform: () => history.push(trashPath()),
|
||||
visible: ({ location }) => location.pathname !== trashPath(),
|
||||
});
|
||||
|
||||
export const navigateToSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "s"],
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
perform: () => window.open(developersUrl()),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
perform: () => window.open(mailToUrl()),
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createAction({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
section: NavigationSection,
|
||||
perform: () => window.open(githubIssuesUrl()),
|
||||
});
|
||||
|
||||
export const openChangelog = createAction({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
perform: () => window.open(changelogUrl()),
|
||||
});
|
||||
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
name: ({ t }) => t("Keyboard shortcuts"),
|
||||
section: NavigationSection,
|
||||
shortcut: ["?"],
|
||||
iconInContextMenu: false,
|
||||
icon: <KeyboardIcon />,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openGuide({
|
||||
title: t("Keyboard shortcuts"),
|
||||
content: <KeyboardShortcuts />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
name: ({ t }) => t("Log out"),
|
||||
section: NavigationSection,
|
||||
icon: <LogoutIcon />,
|
||||
perform: () => stores.auth.logout(),
|
||||
});
|
||||
|
||||
export const rootNavigationActions = [
|
||||
navigateToHome,
|
||||
navigateToSearch,
|
||||
navigateToDrafts,
|
||||
navigateToTemplates,
|
||||
navigateToArchive,
|
||||
navigateToTrash,
|
||||
navigateToSettings,
|
||||
openAPIDocumentation,
|
||||
openFeedbackUrl,
|
||||
openBugReportUrl,
|
||||
openChangelog,
|
||||
openKeyboardShortcuts,
|
||||
logout,
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { createAction } from "~/actions";
|
||||
import { SettingsSection } from "~/actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createAction({
|
||||
name: ({ t }) => t("Dark"),
|
||||
icon: <MoonIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme dark night",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "dark",
|
||||
perform: () => stores.ui.setTheme(Theme.Dark),
|
||||
});
|
||||
|
||||
export const changeToLightTheme = createAction({
|
||||
name: ({ t }) => t("Light"),
|
||||
icon: <SunIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "light",
|
||||
perform: () => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
name: ({ t }) => t("System"),
|
||||
icon: <BrowserIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme system default",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "system",
|
||||
perform: () => stores.ui.setTheme(Theme.System),
|
||||
});
|
||||
|
||||
export const changeTheme = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: () =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
});
|
||||
|
||||
export const rootSettingsActions = [changeTheme];
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import { createAction } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
export const inviteUser = createAction({
|
||||
name: ({ t }) => `${t("Invite people")}…`,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "team member user",
|
||||
section: UserSection,
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Invite people"),
|
||||
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const rootUserActions = [inviteUser];
|
||||
@@ -0,0 +1,114 @@
|
||||
import { flattenDeep } from "lodash";
|
||||
import * as React from "react";
|
||||
import { $Diff } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
CommandBarAction,
|
||||
MenuItemButton,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
|
||||
export function createAction(
|
||||
definition: $Diff<
|
||||
Action,
|
||||
{
|
||||
id?: string;
|
||||
}
|
||||
>
|
||||
): Action {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
...definition,
|
||||
};
|
||||
}
|
||||
|
||||
export function actionToMenuItem(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): MenuItemButton | MenuItemWithChildren {
|
||||
function resolve<T>(value: any): T {
|
||||
if (typeof value === "function") {
|
||||
return value(context);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
|
||||
const resolvedChildren = resolve<Action[]>(action.children);
|
||||
const visible = action.visible ? action.visible(context) : true;
|
||||
const title = resolve<string>(action.name);
|
||||
const icon =
|
||||
resolvedIcon && action.iconInContextMenu !== false
|
||||
? React.cloneElement(resolvedIcon, {
|
||||
color: "currentColor",
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (resolvedChildren) {
|
||||
return {
|
||||
type: "submenu",
|
||||
title,
|
||||
icon,
|
||||
items: resolvedChildren
|
||||
.map((a) => actionToMenuItem(a, context))
|
||||
.filter((a) => !!a),
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "button",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
onClick: () => action.perform && action.perform(context),
|
||||
selected: action.selected ? action.selected(context) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function actionToKBar(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): CommandBarAction[] {
|
||||
function resolve<T>(value: any): T {
|
||||
if (typeof value === "function") {
|
||||
return value(context);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof action.visible === "function" && !action.visible(context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
|
||||
const resolvedChildren = resolve<Action[]>(action.children);
|
||||
const resolvedSection = resolve<string>(action.section);
|
||||
const resolvedName = resolve<string>(action.name);
|
||||
const resolvedPlaceholder = resolve<string>(action.placeholder);
|
||||
const children = resolvedChildren
|
||||
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
|
||||
(a) => !!a
|
||||
)
|
||||
: [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name: resolvedName,
|
||||
section: resolvedSection,
|
||||
placeholder: resolvedPlaceholder,
|
||||
keywords: `${action.keywords}`,
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
perform: action.perform
|
||||
? () => action.perform && action.perform(context)
|
||||
: undefined,
|
||||
},
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
].concat(children.map((child) => ({ ...child, parent: action.id })));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { rootCollectionActions } from "./definitions/collections";
|
||||
import { rootDebugActions } from "./definitions/debug";
|
||||
import { rootDocumentActions } from "./definitions/documents";
|
||||
import { rootNavigationActions } from "./definitions/navigation";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
import { rootUserActions } from "./definitions/users";
|
||||
|
||||
export default [
|
||||
...rootCollectionActions,
|
||||
...rootDocumentActions,
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootSettingsActions,
|
||||
...rootDebugActions,
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const DebugSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
|
||||
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
@@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "components/Flex";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
@@ -33,7 +32,7 @@ const Actions = styled(Flex)`
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
padding: 12px;
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
@@ -1,34 +1,39 @@
|
||||
// @flow
|
||||
/* global ga */
|
||||
import * as React from "react";
|
||||
import env from "env";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) return;
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
window.ga ||
|
||||
function () {
|
||||
// $FlowIssue
|
||||
(ga.q = ga.q || []).push(arguments);
|
||||
function (...args) {
|
||||
(ga.q = ga.q || []).push(args);
|
||||
};
|
||||
|
||||
// $FlowIssue
|
||||
ga.l = +new Date();
|
||||
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
|
||||
ga("set", { dimension1: "true" });
|
||||
ga("set", {
|
||||
dimension1: "true",
|
||||
});
|
||||
ga("send", "pageview");
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://www.google-analytics.com/analytics.js";
|
||||
script.async = true;
|
||||
|
||||
// Track PWA install event
|
||||
window.addEventListener("appinstalled", () => {
|
||||
ga("send", "event", "pwa", "install");
|
||||
});
|
||||
|
||||
if (document.body) {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
export default function Arrow() {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="30"
|
||||
viewBox="0 0 13 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
fill?: string,
|
||||
className?: string,
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 34 34"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.0002 1H33.9998C33.9998 5.8172 34.0007 10.6344 33.9988 15.4516C28.6666 15.4508 23.3334 15.4516 18.0012 15.4516C17.9993 10.6344 18.0002 5.8172 18.0002 1Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18.0009 17.5173C23.3333 17.5155 28.6667 17.5164 34 17.5164V33H18C18.0009 27.8388 17.9991 22.6776 18.0009 17.5173V17.5173Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 1H16L15.9988 15.4516H0V1Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 17.5161C5.3332 17.5179 10.6664 17.5155 15.9996 17.5179C16.0005 22.6789 15.9996 27.839 15.9996 33H0V17.5161Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default MicrosoftLogo;
|
||||
@@ -1,10 +1,9 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
fill?: string,
|
||||
className?: string,
|
||||
size?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import GoogleLogo from "./GoogleLogo";
|
||||
import MicrosoftLogo from "./MicrosoftLogo";
|
||||
import SlackLogo from "./SlackLogo";
|
||||
|
||||
type Props = {
|
||||
providerName: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
function AuthLogo({ providerName, size = 16 }: Props) {
|
||||
switch (providerName) {
|
||||
case "slack":
|
||||
return (
|
||||
<Logo>
|
||||
<SlackLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
case "google":
|
||||
return (
|
||||
<Logo>
|
||||
<GoogleLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
case "azure":
|
||||
return (
|
||||
<Logo>
|
||||
<MicrosoftLogo size={size} />
|
||||
</Logo>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default AuthLogo;
|
||||
@@ -1,15 +1,15 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { isCustomSubdomain } from "shared/utils/domains";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import useStores from "../hooks/useStores";
|
||||
import env from "env";
|
||||
import { isCustomSubdomain } from "@shared/utils/domains";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
const Authenticated = ({ children }: Props) => {
|
||||
@@ -20,11 +20,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
if (language && i18n.language !== language) {
|
||||
// Languages are stored in en_US format in the database, however the
|
||||
// frontend translation framework (i18next) expects en-US
|
||||
i18n.changeLanguage(language.replace("_", "-"));
|
||||
}
|
||||
changeLanguage(language, i18n);
|
||||
}, [i18n, language]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
@@ -0,0 +1,106 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
searchUrl,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import withStores from "./withStores";
|
||||
|
||||
const DocumentHistory = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "document-history" */
|
||||
"~/components/DocumentHistory"
|
||||
)
|
||||
);
|
||||
const CommandBar = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "command-bar" */
|
||||
"~/components/CommandBar"
|
||||
)
|
||||
);
|
||||
|
||||
type Props = WithTranslation &
|
||||
RootStore & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@observer
|
||||
class AuthenticatedLayout extends React.Component<Props> {
|
||||
scrollable: HTMLDivElement | null | undefined;
|
||||
|
||||
@observable
|
||||
keyboardShortcutsOpen = false;
|
||||
|
||||
goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
history.push(searchUrl());
|
||||
}
|
||||
};
|
||||
|
||||
goToNewDocument = () => {
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const can = this.props.policies.abilities(activeCollectionId);
|
||||
if (!can.update) {
|
||||
return;
|
||||
}
|
||||
history.push(newDocumentPath(activeCollectionId));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
if (auth.isSuspended) {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const sidebar = showSidebar ? (
|
||||
<Switch>
|
||||
<Route path={settingsPath()} component={SettingsSidebar} />
|
||||
<Route component={Sidebar} />
|
||||
</Switch>
|
||||
) : undefined;
|
||||
|
||||
const rightRail = (
|
||||
<React.Suspense fallback={null}>
|
||||
<Switch>
|
||||
<Route
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={DocumentHistory}
|
||||
/>
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout title={team?.name} sidebar={sidebar} rightRail={rightRail}>
|
||||
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
|
||||
{this.props.children}
|
||||
<CommandBar />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(withStores(AuthenticatedLayout));
|
||||
@@ -1,23 +1,24 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import User from "~/models/User";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {|
|
||||
src: string,
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
user?: User,
|
||||
onClick?: () => void,
|
||||
className?: string,
|
||||
|};
|
||||
type Props = {
|
||||
src: string;
|
||||
size: number;
|
||||
icon?: React.ReactNode;
|
||||
user?: User;
|
||||
alt?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@observer
|
||||
class Avatar extends React.Component<Props> {
|
||||
@observable error: boolean;
|
||||
@observable
|
||||
error: boolean;
|
||||
|
||||
static defaultProps = {
|
||||
size: 24,
|
||||
@@ -29,7 +30,6 @@ class Avatar extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { src, icon, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<AvatarWrapper>
|
||||
<CircleImg
|
||||
@@ -59,7 +59,7 @@ const IconWrapper = styled.div`
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const CircleImg = styled.img`
|
||||
const CircleImg = styled.img<{ size: number }>`
|
||||
display: block;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
@@ -1,93 +0,0 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import UserProfile from "scenes/UserProfile";
|
||||
import Avatar from "components/Avatar";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
isPresent: boolean,
|
||||
isEditing: boolean,
|
||||
isCurrentUser: boolean,
|
||||
lastViewedAt: string,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AvatarWithPresence extends React.Component<Props> {
|
||||
@observable isOpen: boolean = false;
|
||||
|
||||
handleOpenProfile = () => {
|
||||
this.isOpen = true;
|
||||
};
|
||||
|
||||
handleCloseProfile = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
lastViewedAt,
|
||||
isPresent,
|
||||
isEditing,
|
||||
isCurrentUser,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const action = isPresent
|
||||
? isEditing
|
||||
? t("currently editing")
|
||||
: t("currently viewing")
|
||||
: t("viewed {{ timeAgo }} ago", {
|
||||
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
||||
<br />
|
||||
{action}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<AvatarWrapper isPresent={isPresent}>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={this.handleOpenProfile}
|
||||
size={32}
|
||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||
/>
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
<UserProfile
|
||||
user={user}
|
||||
isOpen={this.isOpen}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
|
||||
@@ -0,0 +1,141 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import User from "~/models/User";
|
||||
import UserProfile from "~/scenes/UserProfile";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
user: User;
|
||||
isPresent: boolean;
|
||||
isEditing: boolean;
|
||||
isObserving: boolean;
|
||||
isCurrentUser: boolean;
|
||||
profileOnClick: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
};
|
||||
|
||||
@observer
|
||||
class AvatarWithPresence extends React.Component<Props> {
|
||||
@observable
|
||||
isOpen = false;
|
||||
|
||||
handleOpenProfile = () => {
|
||||
this.isOpen = true;
|
||||
};
|
||||
|
||||
handleCloseProfile = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
onClick,
|
||||
user,
|
||||
isPresent,
|
||||
isEditing,
|
||||
isObserving,
|
||||
isCurrentUser,
|
||||
t,
|
||||
} = this.props;
|
||||
const status = isPresent
|
||||
? isEditing
|
||||
? t("currently editing")
|
||||
: t("currently viewing")
|
||||
: t("previously edited");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
||||
{status && (
|
||||
<>
|
||||
<br />
|
||||
{status}
|
||||
</>
|
||||
)}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<AvatarWrapper
|
||||
$isPresent={isPresent}
|
||||
$isObserving={isObserving}
|
||||
$color={user.color}
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={
|
||||
this.props.profileOnClick === false
|
||||
? onClick
|
||||
: this.handleOpenProfile
|
||||
}
|
||||
size={32}
|
||||
/>
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
{this.props.profileOnClick && (
|
||||
<UserProfile
|
||||
user={user}
|
||||
isOpen={this.isOpen}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const AvatarWrapper = styled.div<{
|
||||
$isPresent: boolean;
|
||||
$isObserving: boolean;
|
||||
$color: string;
|
||||
}>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 50%;
|
||||
transition: border-color 100ms ease-in-out;
|
||||
border: 2px solid transparent;
|
||||
pointer-events: none;
|
||||
|
||||
${(props) =>
|
||||
props.$isObserving &&
|
||||
css`
|
||||
border: 2px solid ${props.$color};
|
||||
box-shadow: inset 0 0 0 2px ${props.theme.background};
|
||||
|
||||
&:hover {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
border: 2px solid ${(props) => props.$color};
|
||||
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
|
||||
}
|
||||
`;
|
||||
|
||||
export default withTranslation()(AvatarWithPresence);
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import Avatar from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
|
||||
export { AvatarWithPresence };
|
||||
|
||||
export default Avatar;
|
||||
@@ -1,18 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Badge;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
margin-left: 10px;
|
||||
padding: 1px 5px 2px;
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.primary : "transparent"};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
|
||||
border: 1px solid
|
||||
${({ primary, yellow, theme }) =>
|
||||
primary || yellow
|
||||
? "transparent"
|
||||
: transparentize(0.4, theme.textTertiary)};
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Badge;
|
||||
@@ -1,11 +1,11 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import env from "~/env";
|
||||
import OutlineLogo from "./OutlineLogo";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
href?: string,
|
||||
href?: string;
|
||||
};
|
||||
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
@@ -18,9 +18,8 @@ function Branding({ href = env.URL }: Props) {
|
||||
}
|
||||
|
||||
const Link = styled.a`
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
justify-content: center;
|
||||
padding-bottom: 16px;
|
||||
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
@@ -29,7 +28,6 @@ const Link = styled.a`
|
||||
color: ${(props) => props.theme.text};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.text};
|
||||
@@ -38,6 +36,14 @@ const Link = styled.a`
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
z-index: ${(props: any) => props.theme.depths.sidebar + 1};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 16px;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
@@ -1,204 +0,0 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
GoToIcon,
|
||||
PadlockIcon,
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "models/Document";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import Flex from "components/Flex";
|
||||
import useStores from "hooks/useStores";
|
||||
import BreadcrumbMenu from "menus/BreadcrumbMenu";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
onlyText: boolean,
|
||||
};
|
||||
|
||||
function Icon({ document }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.isDeleted) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/trash">
|
||||
<TrashIcon color="currentColor" />
|
||||
|
||||
<span>{t("Trash")}</span>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/archive">
|
||||
<ArchiveIcon color="currentColor" />
|
||||
|
||||
<span>{t("Archive")}</span>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isDraft) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>{t("Drafts")}</span>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isTemplate) {
|
||||
return (
|
||||
<>
|
||||
<CategoryName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>{t("Templates")}</span>
|
||||
</CategoryName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const Breadcrumb = ({ document, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
color: "currentColor",
|
||||
};
|
||||
}
|
||||
|
||||
const path = collection.pathToDocument
|
||||
? collection.pathToDocument(document.id).slice(0, -1)
|
||||
: [];
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
<>
|
||||
{collection.private && (
|
||||
<>
|
||||
<SmallPadlockIcon color="currentColor" size={16} />{" "}
|
||||
</>
|
||||
)}
|
||||
{collection.name}
|
||||
{path.map((n) => (
|
||||
<React.Fragment key={n.id}>
|
||||
<SmallSlash />
|
||||
{n.title}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isNestedDocument = path.length > 1;
|
||||
const lastPath = path.length ? path[path.length - 1] : undefined;
|
||||
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
|
||||
|
||||
return (
|
||||
<Wrapper justify="flex-start" align="center">
|
||||
<Icon document={document} />
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
<span>{collection.name}</span>
|
||||
</CollectionName>
|
||||
{isNestedDocument && (
|
||||
<>
|
||||
<Slash /> <BreadcrumbMenu path={menuPath} />
|
||||
</>
|
||||
)}
|
||||
{lastPath && (
|
||||
<>
|
||||
<Slash />{" "}
|
||||
<Crumb to={lastPath.url} title={lastPath.title}>
|
||||
{lastPath.title}
|
||||
</Crumb>
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
display: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const SmallPadlockIcon = styled(PadlockIcon)`
|
||||
display: inline-block;
|
||||
vertical-align: sub;
|
||||
`;
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 15px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
const Crumb = styled(Link)`
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const CollectionName = styled(Link)`
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryName = styled(CollectionName)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default observer(Breadcrumb);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { GoToIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
|
||||
type Props = {
|
||||
items: MenuInternalLink[];
|
||||
max?: number;
|
||||
children?: React.ReactNode;
|
||||
highlightFirstItem?: boolean;
|
||||
};
|
||||
|
||||
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
|
||||
// chop middle breadcrumbs and present a "..." menu instead
|
||||
if (totalItems > max) {
|
||||
const halfMax = Math.floor(max / 2);
|
||||
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
|
||||
|
||||
topLevelItems.splice(halfMax, 0, {
|
||||
to: "",
|
||||
type: "route",
|
||||
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center">
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={item.to || index}>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const Slash = styled(GoToIcon)`
|
||||
flex-shrink: 0;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Breadcrumb;
|
||||
@@ -1,13 +1,16 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { bounceIn } from "shared/styles/animations";
|
||||
import { bounceIn } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
count: number,
|
||||
count: number;
|
||||
};
|
||||
|
||||
const Bubble = ({ count }: Props) => {
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Count>{count}</Count>;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// @flow
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const RealButton = styled.button`
|
||||
const RealButton = styled.button<{
|
||||
fullwidth?: boolean;
|
||||
borderOnHover?: boolean;
|
||||
$neutral?: boolean;
|
||||
danger?: boolean;
|
||||
iconColor?: string;
|
||||
}>`
|
||||
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
|
||||
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
|
||||
margin: 0;
|
||||
@@ -21,12 +26,13 @@ const RealButton = styled.button`
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
appearance: none !important;
|
||||
|
||||
${(props) =>
|
||||
!props.borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.iconColor || props.theme.buttonText};
|
||||
fill: ${props.iconColor || "currentColor"};
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -35,7 +41,7 @@ const RealButton = styled.button`
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
@@ -43,6 +49,11 @@ const RealButton = styled.button`
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${(props) => props.theme.white50};
|
||||
background: ${(props) => lighten(0.2, props.theme.buttonBackground)};
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white50};
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
@@ -60,13 +71,17 @@ const RealButton = styled.button`
|
||||
props.borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||
fill: ${props.iconColor || "currentColor"};
|
||||
}`
|
||||
}
|
||||
|
||||
|
||||
&: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,20 +89,31 @@ const RealButton = styled.button`
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
background: none;
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
`} ${(props) =>
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.danger &&
|
||||
`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: none;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
const Label = styled.span<{ hasIcon?: boolean }>`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@@ -95,7 +121,11 @@ const Label = styled.span`
|
||||
${(props) => props.hasIcon && "padding-left: 4px;"};
|
||||
`;
|
||||
|
||||
export const Inner = styled.span`
|
||||
export const Inner = styled.span<{
|
||||
disclosure?: boolean;
|
||||
hasIcon?: boolean;
|
||||
hasText?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
@@ -108,56 +138,41 @@ export const Inner = styled.span`
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
export type Props = {|
|
||||
type?: "button" | "submit",
|
||||
value?: string,
|
||||
icon?: React.Node,
|
||||
iconColor?: string,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
neutral?: boolean,
|
||||
danger?: boolean,
|
||||
primary?: boolean,
|
||||
disabled?: boolean,
|
||||
fullwidth?: boolean,
|
||||
autoFocus?: boolean,
|
||||
style?: Object,
|
||||
as?: React.ComponentType<any>,
|
||||
to?: string,
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
borderOnHover?: boolean,
|
||||
export type Props<T> = {
|
||||
icon?: React.ReactNode;
|
||||
iconColor?: string;
|
||||
children?: React.ReactNode;
|
||||
disclosure?: boolean;
|
||||
neutral?: boolean;
|
||||
danger?: boolean;
|
||||
primary?: boolean;
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: string;
|
||||
borderOnHover?: boolean;
|
||||
href?: string;
|
||||
"data-on"?: string;
|
||||
"data-event-category"?: string;
|
||||
"data-event-action"?: string;
|
||||
};
|
||||
|
||||
"data-on"?: string,
|
||||
"data-event-category"?: string,
|
||||
"data-event-action"?: string,
|
||||
|};
|
||||
|
||||
function Button({
|
||||
type = "text",
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
innerRef,
|
||||
neutral,
|
||||
...rest
|
||||
}: Props) {
|
||||
const Button = <T extends React.ElementType = "button">(
|
||||
props: Props<T> & React.ComponentPropsWithoutRef<T>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const { type, icon, children, value, disclosure, neutral, ...rest } = props;
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
|
||||
<RealButton type={type || "button"} ref={ref} $neutral={neutral} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
{disclosure && <ExpandedIcon color="currentColor" />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default React.forwardRef<Props, typeof Button>((props, ref) => (
|
||||
<Button {...props} innerRef={ref} />
|
||||
));
|
||||
export default React.forwardRef(Button);
|
||||
@@ -1,4 +1,3 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "./Button";
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick: (ev: SyntheticEvent<>) => void,
|
||||
children: React.Node,
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function ButtonLink(props: Props) {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
const ButtonLink = React.forwardRef(
|
||||
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
|
||||
return <Button {...props} ref={ref} />;
|
||||
}
|
||||
);
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 0;
|
||||
@@ -21,3 +22,5 @@ const Button = styled.button`
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default ButtonLink;
|
||||
@@ -1,25 +1,29 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
children?: React.ReactNode;
|
||||
withStickyHeader?: boolean;
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
const Container = styled.div<{ withStickyHeader?: boolean }>`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: 60px 20px;
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 60px;
|
||||
padding: ${(props: any) => (props.withStickyHeader ? "4px 60px" : "60px")};
|
||||
`};
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
max-width: 46em;
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: 52em;
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent = ({ children, ...rest }: Props) => {
|
||||
@@ -1,64 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import HelpText from "components/HelpText";
|
||||
|
||||
export type Props = {|
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
name?: string,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
note?: string,
|
||||
short?: boolean,
|
||||
small?: boolean,
|
||||
|};
|
||||
|
||||
const LabelText = styled.span`
|
||||
font-weight: 500;
|
||||
margin-left: ${(props) => (props.small ? "6px" : "10px")};
|
||||
${(props) => (props.small ? `color: ${props.theme.textSecondary}` : "")};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
${(props) => (props.small ? "font-size: 14px" : "")};
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default function Checkbox({
|
||||
label,
|
||||
labelHidden,
|
||||
note,
|
||||
className,
|
||||
small,
|
||||
short,
|
||||
...rest
|
||||
}: Props) {
|
||||
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper small={small}>
|
||||
<Label>
|
||||
<input type="checkbox" {...rest} />
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
</Label>
|
||||
{note && <HelpText small>{note}</HelpText>}
|
||||
</Wrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
|
||||
const cleanPercentage = (percentage: number) => {
|
||||
const tooLow = !Number.isFinite(+percentage) || percentage < 0;
|
||||
const tooHigh = percentage > 100;
|
||||
return tooLow ? 0 : tooHigh ? 100 : +percentage;
|
||||
};
|
||||
|
||||
const Circle = ({
|
||||
color,
|
||||
percentage,
|
||||
offset,
|
||||
}: {
|
||||
color: string;
|
||||
percentage?: number;
|
||||
offset: number;
|
||||
}) => {
|
||||
const radius = offset * 0.7;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
let strokePercentage;
|
||||
|
||||
if (percentage) {
|
||||
// because the circle is so small, anything greater than 85% appears like 100%
|
||||
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
|
||||
strokePercentage = percentage
|
||||
? ((100 - percentage) * circumference) / 100
|
||||
: 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<circle
|
||||
r={radius}
|
||||
cx={offset}
|
||||
cy={offset}
|
||||
fill="none"
|
||||
stroke={strokePercentage !== circumference ? color : ""}
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={percentage ? strokePercentage : 0}
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
transition: "stroke-dashoffset 0.6s ease 0s",
|
||||
}}
|
||||
></circle>
|
||||
);
|
||||
};
|
||||
|
||||
const CircularProgressBar = ({
|
||||
percentage,
|
||||
size = 16,
|
||||
}: {
|
||||
percentage: number;
|
||||
size?: number;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
percentage = cleanPercentage(percentage);
|
||||
const offset = Math.floor(size / 2);
|
||||
|
||||
return (
|
||||
<SVG width={size} height={size}>
|
||||
<g transform={`rotate(-90 ${offset} ${offset})`}>
|
||||
<Circle color={theme.progressBarBackground} offset={offset} />
|
||||
{percentage > 0 && (
|
||||
<Circle
|
||||
color={theme.primary}
|
||||
percentage={percentage}
|
||||
offset={offset}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</SVG>
|
||||
);
|
||||
};
|
||||
|
||||
const SVG = styled.svg`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default CircularProgressBar;
|
||||
@@ -1,7 +1,6 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const ClickablePadding = styled.div`
|
||||
const ClickablePadding = styled.div<{ grow?: boolean }>`
|
||||
min-height: 10em;
|
||||
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
|
||||
${({ grow }) => grow && `flex-grow: 100;`};
|
||||
@@ -1,78 +0,0 @@
|
||||
// @flow
|
||||
import { sortBy, keyBy } from "lodash";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import { AvatarWithPresence } from "components/Avatar";
|
||||
import Facepile from "components/Facepile";
|
||||
|
||||
type Props = {
|
||||
views: ViewsStore,
|
||||
presence: DocumentPresenceStore,
|
||||
document: Document,
|
||||
currentUserId: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!this.props.document.isDeleted) {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { document, presence, views, currentUserId } = this.props;
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const documentViews = views.inDocument(document.id);
|
||||
|
||||
const presentIds = documentPresence.map((p) => p.userId);
|
||||
const editingIds = documentPresence
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const mostRecentViewers = sortBy(
|
||||
documentViews.slice(0, MAX_AVATAR_DISPLAY),
|
||||
(view) => {
|
||||
return presentIds.includes(view.user.id);
|
||||
}
|
||||
);
|
||||
|
||||
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
|
||||
const overflow = documentViews.length - mostRecentViewers.length;
|
||||
|
||||
return (
|
||||
<Facepile
|
||||
users={mostRecentViewers.map((v) => v.user)}
|
||||
overflow={overflow}
|
||||
renderAvatar={(user) => {
|
||||
const isPresent = presentIds.includes(user.id);
|
||||
const isEditing = editingIds.includes(user.id);
|
||||
const { lastViewedAt } = viewersKeyedByUserId[user.id];
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={user.id}
|
||||
user={user}
|
||||
lastViewedAt={lastViewedAt}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isCurrentUser={currentUserId === user.id}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("views", "presence")(Collaborators);
|
||||
@@ -0,0 +1,124 @@
|
||||
import { sortBy, filter, uniq, isEqual } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const currentUserId = user?.id;
|
||||
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
|
||||
const { users, presence, ui } = useStores();
|
||||
const { document } = props;
|
||||
const documentPresence = presence.get(document.id);
|
||||
const documentPresenceArray = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
const presentIds = documentPresenceArray.map((p) => p.userId);
|
||||
const editingIds = documentPresenceArray
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const collaborators = React.useMemo(
|
||||
() =>
|
||||
sortBy(
|
||||
filter(
|
||||
users.orderedData,
|
||||
(user) =>
|
||||
presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)
|
||||
),
|
||||
(user) => presentIds.includes(user.id)
|
||||
),
|
||||
[document.collaboratorIds, users.orderedData, presentIds]
|
||||
);
|
||||
|
||||
// load any users we don't yet have in memory
|
||||
React.useEffect(() => {
|
||||
const userIdsToFetch = uniq([
|
||||
...document.collaboratorIds,
|
||||
...presentIds,
|
||||
]).filter((userId) => !users.get(userId));
|
||||
|
||||
if (!isEqual(requestedUserIds, userIdsToFetch)) {
|
||||
setRequestedUserIds(userIdsToFetch);
|
||||
}
|
||||
|
||||
userIdsToFetch
|
||||
.filter((userId) => requestedUserIds.includes(userId))
|
||||
.forEach((userId) => users.fetch(userId));
|
||||
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<NudeButton width={collaborators.length * 32} height={32} {...props}>
|
||||
<Facepile
|
||||
users={collaborators}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
const isEditing = editingIds.includes(collaborator.id);
|
||||
const isObserving = ui.observingUserId === collaborator.id;
|
||||
const isObservable =
|
||||
team.collaborativeEditing && collaborator.id !== user.id;
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isObserving={isObserving}
|
||||
isCurrentUser={currentUserId === collaborator.id}
|
||||
profileOnClick={false}
|
||||
onClick={
|
||||
isObservable
|
||||
? (ev) => {
|
||||
if (isPresent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ui.setObservingUser(
|
||||
isObserving ? undefined : collaborator.id
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</NudeButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Collaborators);
|
||||
@@ -0,0 +1,226 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections, policies } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleStopEditing = React.useCallback(() => {
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleClickDisclosure = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isExpanded && document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setExpanded(!isExpanded);
|
||||
},
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = useDebouncedCallback(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
t("Sorry, an error occurred saving the collection", {
|
||||
type: "error",
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(getValue) => {
|
||||
setDirty(true);
|
||||
handleSave(getValue);
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [collection.id]);
|
||||
const placeholder = `${t("Add a description")}…`;
|
||||
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
|
||||
|
||||
return (
|
||||
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<Input data-editing={isEditing} data-expanded={isExpanded}>
|
||||
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{collection.hasDescription || isEditing || isDirty ? (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
Loading…
|
||||
</Placeholder>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
key={key}
|
||||
defaultValue={collection.description || ""}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
maxLength={1000}
|
||||
embedsDisabled
|
||||
readOnlyWriteCheckboxes
|
||||
grow
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
can.update && (
|
||||
<Placeholder
|
||||
onClick={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
{placeholder}
|
||||
</Placeholder>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</Input>
|
||||
{!isEditing && (
|
||||
<Disclosure
|
||||
onClick={handleClickDisclosure}
|
||||
aria-label={isExpanded ? t("Collapse") : t("Expand")}
|
||||
size={30}
|
||||
>
|
||||
<Arrow />
|
||||
</Disclosure>
|
||||
)}
|
||||
</MaxHeight>
|
||||
);
|
||||
}
|
||||
|
||||
const Disclosure = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${(props) => props.theme.divider};
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
transform: rotate(-90deg) translateX(-50%);
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
`;
|
||||
|
||||
const Placeholder = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
cursor: text;
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
const MaxHeight = styled.div`
|
||||
position: relative;
|
||||
max-height: 25vh;
|
||||
overflow: hidden;
|
||||
margin: -12px -8px -8px;
|
||||
padding: 8px;
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
max-height: initial;
|
||||
overflow: initial;
|
||||
|
||||
${Disclosure} {
|
||||
top: initial;
|
||||
bottom: 0;
|
||||
transform: rotate(90deg) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover ${Disclosure} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(25vh - 50px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => props.theme.background} 100%
|
||||
);
|
||||
}
|
||||
|
||||
&[data-editing="true"],
|
||||
&[data-expanded="true"] {
|
||||
&:after {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CollectionDescription);
|
||||
@@ -1,45 +0,0 @@
|
||||
// @flow
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { PrivateCollectionIcon, CollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Collection from "models/Collection";
|
||||
import { icons } from "components/IconPicker";
|
||||
|
||||
type Props = {
|
||||
collection: Collection,
|
||||
expanded?: boolean,
|
||||
size?: number,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
const color =
|
||||
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
|
||||
? getLuminance(collection.color) > 0.12
|
||||
? collection.color
|
||||
: "currentColor"
|
||||
: collection.color;
|
||||
|
||||
if (collection.icon && collection.icon !== "collection") {
|
||||
try {
|
||||
const Component = icons[collection.icon].component;
|
||||
return <Component color={color} size={size} />;
|
||||
} catch (error) {
|
||||
console.warn("Failed to render custom icon " + collection.icon);
|
||||
}
|
||||
}
|
||||
|
||||
if (collection.private) {
|
||||
return (
|
||||
<PrivateCollectionIcon color={color} expanded={expanded} size={size} />
|
||||
);
|
||||
}
|
||||
|
||||
return <CollectionIcon color={color} expanded={expanded} size={size} />;
|
||||
}
|
||||
|
||||
export default inject("ui")(observer(ResolvedCollectionIcon));
|
||||
@@ -0,0 +1,46 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import { icons } from "~/components/IconPicker";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
expanded?: boolean;
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({
|
||||
collection,
|
||||
color: inputColor,
|
||||
expanded,
|
||||
size,
|
||||
}: 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 =
|
||||
inputColor ||
|
||||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
|
||||
? getLuminance(collection.color) > 0.09
|
||||
? collection.color
|
||||
: "currentColor"
|
||||
: collection.color);
|
||||
|
||||
if (collection.icon && collection.icon !== "collection") {
|
||||
try {
|
||||
const Component = icons[collection.icon].component;
|
||||
return <Component color={color} size={size} />;
|
||||
} catch (error) {
|
||||
console.warn("Failed to render custom icon " + collection.icon);
|
||||
}
|
||||
}
|
||||
|
||||
return <CollectionIcon color={color} expanded={expanded} size={size} />;
|
||||
}
|
||||
|
||||
export default observer(ResolvedCollectionIcon);
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import CommandBarResults from "~/components/CommandBarResults";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import { CommandBarAction } from "~/types";
|
||||
|
||||
export const CommandBarOptions = {
|
||||
animations: {
|
||||
enterMs: 250,
|
||||
exitMs: 200,
|
||||
},
|
||||
};
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
useCommandBarActions(rootActions);
|
||||
|
||||
const { rootAction } = useKBar((state) => ({
|
||||
rootAction: state.currentRootActionId
|
||||
? ((state.actions[
|
||||
state.currentRootActionId
|
||||
] as unknown) as CommandBarAction)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<KBarPortal>
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchInput
|
||||
placeholder={`${
|
||||
rootAction?.placeholder ||
|
||||
rootAction?.name ||
|
||||
t("Type a command or search")
|
||||
}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
</KBarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function KBarPortal({ children }: { children: React.ReactNode }) {
|
||||
const { showing } = useKBar((state) => ({
|
||||
showing: state.visualState !== "hidden",
|
||||
}));
|
||||
|
||||
if (!showing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Portal>{children}</Portal>;
|
||||
}
|
||||
|
||||
const Positioner = styled(KBarPositioner)`
|
||||
z-index: ${(props) => props.theme.depths.commandBar};
|
||||
`;
|
||||
|
||||
const SearchInput = styled(KBarSearch)`
|
||||
padding: 16px 20px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
const Animator = styled(KBarAnimator)`
|
||||
max-width: 600px;
|
||||
max-height: 75vh;
|
||||
width: 90vw;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
|
||||
transition: max-width 0.2s ease-in-out;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: 740px;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CommandBar);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ActionImpl } from "kbar";
|
||||
import { ArrowIcon, BackIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
active: boolean;
|
||||
currentRootActionId: string | null | undefined;
|
||||
};
|
||||
|
||||
function CommandBarItem(
|
||||
{ action, active, currentRootActionId }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const ancestors = React.useMemo(() => {
|
||||
if (!currentRootActionId) {
|
||||
return action.ancestors;
|
||||
}
|
||||
const index = action.ancestors.findIndex(
|
||||
(ancestor) => ancestor.id === currentRootActionId
|
||||
);
|
||||
|
||||
// +1 removes the currentRootAction; e.g. if we are on the "Set theme"
|
||||
// parent action, the UI should not display "Set theme… > Dark" but rather
|
||||
// just "Dark"
|
||||
return action.ancestors.slice(index + 1);
|
||||
}, [action.ancestors, currentRootActionId]);
|
||||
|
||||
return (
|
||||
<Item active={active} ref={ref}>
|
||||
<Content align="center" gap={8}>
|
||||
<Icon>
|
||||
{action.icon ? (
|
||||
// @ts-expect-error no icon on ActionImpl
|
||||
React.cloneElement(action.icon, {
|
||||
size: 22,
|
||||
color: "currentColor",
|
||||
})
|
||||
) : (
|
||||
<ArrowIcon color="currentColor" />
|
||||
)}
|
||||
</Icon>
|
||||
|
||||
{ancestors.map((ancestor) => (
|
||||
<React.Fragment key={ancestor.id}>
|
||||
<Ancestor>{ancestor.name}</Ancestor>
|
||||
<ForwardIcon color={theme.textSecondary} size={22} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
{action.name}
|
||||
{action.children?.length ? "…" : ""}
|
||||
</Content>
|
||||
{action.shortcut?.length ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridAutoFlow: "column",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{action.shortcut.map((sc: string) => (
|
||||
<Key key={sc}>{sc}</Key>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Ancestor = styled.span`
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
`;
|
||||
|
||||
const Item = styled.div<{ active?: boolean }>`
|
||||
font-size: 15px;
|
||||
padding: 10px 16px;
|
||||
background: ${(props) =>
|
||||
props.active ? props.theme.menuItemSelected : "none"};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
${Icon} {
|
||||
color: ${props.theme.text};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ForwardIcon = styled(BackIcon)`
|
||||
transform: rotate(180deg);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLDivElement, Props>(CommandBarItem);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMatches, KBarResults } from "kbar";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import CommandBarItem from "~/components/CommandBarItem";
|
||||
|
||||
export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
|
||||
return (
|
||||
<KBarResults
|
||||
items={results}
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
active={active}
|
||||
currentRootActionId={rootActionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
height: 36px;
|
||||
`;
|
||||
@@ -0,0 +1,58 @@
|
||||
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 you’re 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);
|
||||
@@ -0,0 +1,120 @@
|
||||
import isPrintableKeyEvent from "is-printable-key-event";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onChange?: (text: string) => void;
|
||||
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
children?: React.ReactNode;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines a content editable component with the same interface as a native
|
||||
* HTMLInputElement (or, as close as we can get).
|
||||
*/
|
||||
const ContentEditable = React.forwardRef(
|
||||
(
|
||||
{
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
maxLength,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
readOnly,
|
||||
dir,
|
||||
onClick,
|
||||
...rest
|
||||
}: Props,
|
||||
forwardedRef: React.RefObject<HTMLSpanElement>
|
||||
) => {
|
||||
const innerRef = React.useRef<HTMLSpanElement>(null);
|
||||
const ref = forwardedRef || innerRef;
|
||||
const [innerValue, setInnerValue] = React.useState<string>(value);
|
||||
const lastValue = React.useRef("");
|
||||
|
||||
const wrappedEvent = (
|
||||
callback:
|
||||
| React.FocusEventHandler<HTMLSpanElement>
|
||||
| React.FormEventHandler<HTMLSpanElement>
|
||||
| React.KeyboardEventHandler<HTMLSpanElement>
|
||||
| undefined
|
||||
) => (event: any) => {
|
||||
const text = ref.current?.innerText || "";
|
||||
|
||||
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
|
||||
event?.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (text !== lastValue.current) {
|
||||
lastValue.current = text;
|
||||
onChange && onChange(text);
|
||||
}
|
||||
|
||||
callback?.(event);
|
||||
};
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocus) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [autoFocus, ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== ref.current?.innerText) {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}, [value, ref]);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<Content
|
||||
ref={ref}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
data-placeholder={placeholder}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
{...rest}
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const Content = styled.span`
|
||||
&:empty {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&:empty::before {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ContentEditable;
|
||||
@@ -1,4 +1,3 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Header = styled.h3`
|
||||
@@ -1,101 +0,0 @@
|
||||
// @flow
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {|
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
to?: string,
|
||||
href?: string,
|
||||
target?: "_blank",
|
||||
as?: string | React.ComponentType<*>,
|
||||
|};
|
||||
|
||||
const MenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
...rest
|
||||
}: Props) => {
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<MenuAnchor as={onClick ? "button" : as} {...props}>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
svg:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.disabled
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
export default MenuItem;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { hover } from "~/styles";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
onClick?: (arg0: React.SyntheticEvent) => void | Promise<void>;
|
||||
children?: React.ReactNode;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: string;
|
||||
href?: string;
|
||||
target?: "_blank";
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
const MenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
icon,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onClick(ev);
|
||||
}
|
||||
|
||||
if (hide) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
[onClick, hide]
|
||||
);
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
>
|
||||
{(props) => (
|
||||
<MenuAnchor
|
||||
{...props}
|
||||
$toggleable={selected !== undefined}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
{selected ? <CheckmarkIcon color="currentColor" /> : <Spacer />}
|
||||
|
||||
</>
|
||||
)}
|
||||
{icon && (
|
||||
<MenuIconWrapper>
|
||||
{React.cloneElement(icon, { color: "currentColor" })}
|
||||
</MenuIconWrapper>
|
||||
)}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuAnchorCSS = css<{
|
||||
level?: number;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
color: ${(props) =>
|
||||
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
svg:not(:last-child) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.disabled
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:${hover},
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
`};
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
export default MenuItem;
|
||||
+7
-3
@@ -1,14 +1,18 @@
|
||||
// @flow
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentProps<typeof MenuButton> & {
|
||||
className?: string;
|
||||
iconColor?: string;
|
||||
};
|
||||
|
||||
export default function OverflowMenuButton({
|
||||
iconColor,
|
||||
className,
|
||||
...rest
|
||||
}: any) {
|
||||
}: Props) {
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
@@ -1,9 +1,8 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: {}) {
|
||||
export default function Separator(rest: any) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
@@ -1,169 +0,0 @@
|
||||
// @flow
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
} from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type TMenuItem =
|
||||
| {|
|
||||
title: React.Node,
|
||||
to: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
href: string,
|
||||
visible?: boolean,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
|}
|
||||
| {|
|
||||
title: React.Node,
|
||||
visible?: boolean,
|
||||
disabled?: boolean,
|
||||
style?: Object,
|
||||
hover?: boolean,
|
||||
items: TMenuItem[],
|
||||
|}
|
||||
| {|
|
||||
type: "separator",
|
||||
visible?: boolean,
|
||||
|}
|
||||
| {|
|
||||
type: "heading",
|
||||
visible?: boolean,
|
||||
title: React.Node,
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
items: TMenuItem[],
|
||||
|};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
justify-self: flex-end;
|
||||
`;
|
||||
|
||||
const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor {...props}>
|
||||
{title} <Disclosure color="currentColor" />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
filtered = filtered.reduce((acc, item, index) => {
|
||||
// trim separators from start / end
|
||||
if (item.type === "separator" && index === 0) return acc;
|
||||
if (item.type === "separator" && index === filtered.length - 1) return acc;
|
||||
|
||||
// trim double separators looking ahead / behind
|
||||
const prev = filtered[index - 1];
|
||||
if (prev && prev.type === "separator" && item.type === "separator")
|
||||
return acc;
|
||||
|
||||
// otherwise, continue
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered.map((item, index) => {
|
||||
if (item.to) {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<MenuItem
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
target="_blank"
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
return (
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
key={index}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.items) {
|
||||
return (
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={Submenu}
|
||||
templateItems={item.items}
|
||||
title={item.title}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={index} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
@@ -0,0 +1,218 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
} from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import MenuIconWrapper from "~/components/MenuIconWrapper";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
MenuSeparator,
|
||||
MenuHeading,
|
||||
MenuItem as TMenuItem,
|
||||
} from "~/types";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import Separator from "./Separator";
|
||||
import ContextMenu from ".";
|
||||
|
||||
type Props = {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
const Submenu = React.forwardRef(
|
||||
(
|
||||
{
|
||||
templateItems,
|
||||
title,
|
||||
...rest
|
||||
}: { templateItems: TMenuItem[]; title: React.ReactNode },
|
||||
ref: React.LegacyRef<HTMLButtonElement>
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const menu = useMenuState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton ref={ref} {...menu} {...rest}>
|
||||
{(props) => (
|
||||
<MenuAnchor {...props}>
|
||||
{title} <Disclosure color={theme.textTertiary} />
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Submenu")}>
|
||||
<Template {...menu} items={templateItems} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unnecessary separators
|
||||
filtered = filtered.reduce((acc, item, index) => {
|
||||
// trim separators from start / end
|
||||
if (item.type === "separator" && index === 0) {
|
||||
return acc;
|
||||
}
|
||||
if (item.type === "separator" && index === filtered.length - 1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// trim double separators looking ahead / behind
|
||||
const prev = filtered[index - 1];
|
||||
if (prev && prev.type === "separator" && item.type === "separator") {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// otherwise, continue
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
const ctx = useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const templateItems = actions
|
||||
? actions.map((item) =>
|
||||
item.type === "separator" || item.type === "heading"
|
||||
? item
|
||||
: actionToMenuItem(item, ctx)
|
||||
)
|
||||
: items || [];
|
||||
|
||||
const filteredTemplates = filterTemplateItems(templateItems);
|
||||
|
||||
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
|
||||
(item) =>
|
||||
item.type !== "separator" && item.type !== "heading" && !!item.icon
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredTemplates.map((item, index) => {
|
||||
if (
|
||||
iconIsPresentInAnyMenuItem &&
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading"
|
||||
) {
|
||||
item.icon = item.icon || <MenuIconWrapper />;
|
||||
}
|
||||
|
||||
if (item.type === "route") {
|
||||
return (
|
||||
<MenuItem
|
||||
as={Link}
|
||||
to={item.to}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
icon={item.icon}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<MenuItem
|
||||
href={item.href}
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||
icon={item.icon}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "button") {
|
||||
return (
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
{...menu}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
return (
|
||||
<BaseMenuItem
|
||||
key={index}
|
||||
as={Submenu}
|
||||
templateItems={item.items}
|
||||
title={<Title title={item.title} icon={item.icon} />}
|
||||
{...menu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "separator") {
|
||||
return <Separator key={index} />;
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return <Header>{item.title}</Header>;
|
||||
}
|
||||
|
||||
const _exhaustiveCheck: never = item;
|
||||
return _exhaustiveCheck;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({
|
||||
title,
|
||||
icon,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Flex align="center">
|
||||
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
|
||||
{title}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo<Props>(Template);
|
||||
@@ -1,77 +0,0 @@
|
||||
// @flow
|
||||
import { rgba } from "polished";
|
||||
import * as React from "react";
|
||||
import { Menu } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {|
|
||||
"aria-label": string,
|
||||
visible?: boolean,
|
||||
animating?: boolean,
|
||||
children: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
|};
|
||||
|
||||
export default function ContextMenu({
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
}: Props) {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
if (!rest.visible && previousVisible) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
return (
|
||||
<Menu {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
|
||||
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
max-width: 276px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,177 @@
|
||||
import * as React from "react";
|
||||
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,
|
||||
fadeAndSlideUp,
|
||||
fadeAndSlideDown,
|
||||
mobileContextMenu,
|
||||
} from "~/styles/animations";
|
||||
|
||||
export type Placement =
|
||||
| "auto-start"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "top-start"
|
||||
| "top"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right"
|
||||
| "right-end"
|
||||
| "bottom-end"
|
||||
| "bottom"
|
||||
| "bottom-start"
|
||||
| "left-end"
|
||||
| "left"
|
||||
| "left-start";
|
||||
|
||||
type Props = {
|
||||
"aria-label": string;
|
||||
visible?: boolean;
|
||||
placement?: Placement;
|
||||
animating?: boolean;
|
||||
children: React.ReactNode;
|
||||
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
hide?: () => void;
|
||||
};
|
||||
|
||||
export default function ContextMenu({
|
||||
children,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
}: Props) {
|
||||
const previousVisible = usePrevious(rest.visible);
|
||||
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
|
||||
const backgroundRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.visible && !previousVisible) {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
if (!rest.visible && previousVisible) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [onOpen, onClose, previousVisible, rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// sets the menu height based on the available space between the disclosure/
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => {
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
// positioning.
|
||||
const topAnchor = props.style?.top === "0";
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
|
||||
const rightAnchor = props.placement === "bottom-end";
|
||||
|
||||
return (
|
||||
<Position {...props}>
|
||||
<Background
|
||||
dir="auto"
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
);
|
||||
}}
|
||||
</Menu>
|
||||
{(rest.visible || rest.animating) && (
|
||||
<Portal>
|
||||
<Backdrop onClick={rest.hide} />
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const Backdrop = styled.div`
|
||||
animation: ${fadeIn} 200ms ease-in-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${(props) => props.theme.backdrop};
|
||||
z-index: ${(props) => props.theme.depths.menu - 1};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
export const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
|
||||
// overrides make mobile-first coding style challenging
|
||||
// so we explicitly define mobile breakpoint here
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
top: auto !important;
|
||||
right: 8px !important;
|
||||
bottom: 16px !important;
|
||||
left: 8px !important;
|
||||
`};
|
||||
`;
|
||||
|
||||
export const Background = styled.div<{
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
}>`
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
min-height: 44px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${(props: any) =>
|
||||
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: ${(props: any) => (props.rightAnchor ? "75%" : "25%")} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props: any) => props.theme.menuBackground};
|
||||
box-shadow: ${(props: any) => props.theme.menuShadow};
|
||||
`};
|
||||
`;
|
||||
@@ -1,23 +1,25 @@
|
||||
// @flow
|
||||
import copy from "copy-to-clipboard";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
text: string,
|
||||
children?: React.Node,
|
||||
onClick?: () => void,
|
||||
onCopy: () => void,
|
||||
text: string;
|
||||
children?: React.ReactElement;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onCopy: () => void;
|
||||
};
|
||||
|
||||
class CopyToClipboard extends React.PureComponent<Props> {
|
||||
onClick = (ev: SyntheticEvent<>) => {
|
||||
onClick = (ev: React.SyntheticEvent) => {
|
||||
const { text, onCopy, children } = this.props;
|
||||
const elem = React.Children.only(children);
|
||||
|
||||
copy(text, {
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
|
||||
if (onCopy) onCopy();
|
||||
if (onCopy) {
|
||||
onCopy();
|
||||
}
|
||||
|
||||
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
||||
elem.props.onClick(ev);
|
||||
@@ -25,8 +27,12 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { text: _text, onCopy: _onCopy, children, ...rest } = this.props;
|
||||
const { text, onCopy, children, ...rest } = this.props;
|
||||
const elem = React.Children.only(children);
|
||||
if (!elem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.cloneElement(elem, { ...rest, onClick: this.onClick });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
onSelectCollection: (collection: string) => void;
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(
|
||||
t("Collections could not be loaded, please reload the app"),
|
||||
{
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [showToast, fetchError, t, fetching, collections]);
|
||||
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
collections.publicCollections.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<CollectionIcon collection={collection} />
|
||||
</IconWrapper>
|
||||
{collection.name}
|
||||
</Flex>
|
||||
),
|
||||
value: collection.id,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<HomeIcon color="currentColor" />
|
||||
</IconWrapper>
|
||||
{t("Home")}
|
||||
</Flex>
|
||||
),
|
||||
value: "home",
|
||||
},
|
||||
]
|
||||
),
|
||||
[collections.publicCollections, t]
|
||||
);
|
||||
|
||||
if (fetching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
value={defaultCollectionId ?? "home"}
|
||||
label={t("Start view")}
|
||||
options={options}
|
||||
onChange={onSelectCollection}
|
||||
ariaLabel={t("Default collection")}
|
||||
note={t(
|
||||
"This is the screen that team members will first see when they sign in."
|
||||
)}
|
||||
short
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultCollectionInputSelect;
|
||||
@@ -1,9 +1,8 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
delay?: number,
|
||||
children: React.Node,
|
||||
delay?: number;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
export default function DelayedMount({ delay = 250, children }: Props) {
|
||||
@@ -0,0 +1,35 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import * as React from "react";
|
||||
import Guide from "~/components/Guide";
|
||||
import Modal from "~/components/Modal";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function Dialogs() {
|
||||
const { dialogs } = useStores();
|
||||
const { guide, modalStack } = dialogs;
|
||||
return (
|
||||
<>
|
||||
{guide ? (
|
||||
<Guide
|
||||
isOpen={guide.isOpen}
|
||||
onRequestClose={dialogs.closeGuide}
|
||||
title={guide.title}
|
||||
>
|
||||
{guide.content}
|
||||
</Guide>
|
||||
) : undefined}
|
||||
{[...modalStack].map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
title={modal.title}
|
||||
>
|
||||
{modal.content}
|
||||
</Modal>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Dialogs);
|
||||
@@ -0,0 +1,10 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const Divider = styled.hr`
|
||||
border: 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export default Divider;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink, NavigationNode } from "~/types";
|
||||
import { collectionUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
children?: React.ReactNode;
|
||||
onlyText?: boolean;
|
||||
};
|
||||
|
||||
function useCategory(document: Document): MenuInternalLink | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.isDeleted) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <TrashIcon color="currentColor" />,
|
||||
title: t("Trash"),
|
||||
to: "/trash",
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isArchived) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon color="currentColor" />,
|
||||
title: t("Archive"),
|
||||
to: "/archive",
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isTemplate) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ShapesIcon color="currentColor" />,
|
||||
title: t("Templates"),
|
||||
to: "/templates",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
let collectionNode: MenuInternalLink;
|
||||
|
||||
if (collection) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionUrl(collection.url),
|
||||
};
|
||||
} else {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: t("Deleted Collection"),
|
||||
icon: undefined,
|
||||
to: collectionUrl("deleted-collection"),
|
||||
};
|
||||
}
|
||||
|
||||
const path = React.useMemo(
|
||||
() => collection?.pathToDocument?.(document.id).slice(0, -1) || [],
|
||||
[collection, document]
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const output = [];
|
||||
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
output.push(collectionNode);
|
||||
|
||||
path.forEach((node: NavigationNode) => {
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.title,
|
||||
to: node.url,
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [path, category, collectionNode]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
<>
|
||||
{collection?.name}
|
||||
{path.map((node: NavigationNode) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<SmallSlash />
|
||||
{node.title}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <Breadcrumb items={items} children={children} highlightFirstItem />;
|
||||
};
|
||||
|
||||
const SmallSlash = styled(GoToIcon)`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
|
||||
fill: ${(props) => props.theme.slate};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
export default observer(DocumentBreadcrumb);
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { getLuminance, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionIcon from "./CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
type Props = {
|
||||
/** The pin record */
|
||||
pin: Pin | undefined;
|
||||
/** The document related to the pin */
|
||||
document: Document;
|
||||
/** Whether the user has permission to delete or reorder the pin */
|
||||
canUpdatePin?: boolean;
|
||||
/** Whether this pin can be reordered by dragging */
|
||||
isDraggable?: boolean;
|
||||
};
|
||||
|
||||
function DocumentCard(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: props.document.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleUnpin = React.useCallback(() => {
|
||||
pin?.delete();
|
||||
}, [pin]);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
$isDragging={isDragging}
|
||||
{...attributes}
|
||||
>
|
||||
<AnimatePresence
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
bounce: 0.6,
|
||||
},
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<DocumentLink
|
||||
dir={document.dir}
|
||||
style={{
|
||||
background:
|
||||
collection?.color && getLuminance(collection.color) < 0.6
|
||||
? collection.color
|
||||
: undefined,
|
||||
}}
|
||||
$isDragging={isDragging}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Content justify="space-between" column>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "collection" &&
|
||||
!pin?.collectionId ? (
|
||||
<CollectionIcon collection={collection} color="white" />
|
||||
) : (
|
||||
<DocumentIcon color="white" />
|
||||
)}
|
||||
<div>
|
||||
<Heading dir={document.dir}>{document.titleWithDefault}</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<ClockIcon color="currentColor" size={18} />{" "}
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
</DocumentLink>
|
||||
{canUpdatePin && (
|
||||
<Actions dir={document.dir} gap={4}>
|
||||
{!isDragging && pin && (
|
||||
<Tooltip tooltip={t("Unpin")}>
|
||||
<PinButton onClick={handleUnpin}>
|
||||
<CloseIcon color="currentColor" />
|
||||
</PinButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDraggable && (
|
||||
<DragHandle $isDragging={isDragging} {...listeners}>
|
||||
:::
|
||||
</DragHandle>
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Reorderable>
|
||||
);
|
||||
}
|
||||
|
||||
const PinButton = styled(NudeButton)`
|
||||
color: ${(props) => props.theme.white75};
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: ${(props) => props.theme.white};
|
||||
}
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: ${(props) => (props.dir === "rtl" ? "auto" : "12px")};
|
||||
left: ${(props) => (props.dir === "rtl" ? "12px" : "auto")};
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
// move actions above content
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
const DragHandle = styled.div<{ $isDragging: boolean }>`
|
||||
cursor: ${(props) => (props.$isDragging ? "grabbing" : "grab")};
|
||||
padding: 0 4px;
|
||||
font-weight: bold;
|
||||
color: ${(props) => props.theme.white75};
|
||||
line-height: 1.35;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: ${(props) => props.theme.white};
|
||||
}
|
||||
`;
|
||||
|
||||
const AnimatePresence = m.div;
|
||||
|
||||
const Reorderable = styled.div<{ $isDragging: boolean }>`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
|
||||
// move above other cards when dragging
|
||||
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
|
||||
transform: scale(${(props) => (props.$isDragging ? "1.025" : "1")});
|
||||
box-shadow: ${(props) =>
|
||||
props.$isDragging ? "0 0 20px rgba(0,0,0,0.3);" : "0 0 0 rgba(0,0,0,0)"};
|
||||
|
||||
&:hover ${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
|
||||
// move content above ::after
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const DocumentMeta = styled(Text)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: ${(props) => transparentize(0.25, props.theme.white)};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
$menuOpen?: boolean;
|
||||
$isDragging?: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
height: 160px;
|
||||
background: ${(props) => props.theme.slate};
|
||||
color: ${(props) => props.theme.white};
|
||||
transition: transform 50ms ease-in-out;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
!props.$isDragging &&
|
||||
css`
|
||||
&:after {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.35em;
|
||||
line-height: 22px;
|
||||
max-height: 66px; // 3*line-height
|
||||
overflow: hidden;
|
||||
|
||||
color: ${(props) => props.theme.white};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCard);
|
||||
@@ -0,0 +1,132 @@
|
||||
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: Event[] = [];
|
||||
|
||||
function DocumentHistory() {
|
||||
const { events, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
const eventsInDocument = document
|
||||
? events.inDocument(document.id)
|
||||
: EMPTY_ARRAY;
|
||||
|
||||
const onCloseHistory = () => {
|
||||
if (document) {
|
||||
history.push(documentUrl(document));
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
events
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return eventsInDocument;
|
||||
}, [eventsInDocument, events, 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 { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
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 we’re 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>
|
||||
<ListPlaceholder 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/format";
|
||||
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(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);
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentHistory from "./DocumentHistory";
|
||||
export default DocumentHistory;
|
||||
@@ -1,22 +1,20 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentListItem from "~/components/DocumentListItem";
|
||||
|
||||
type Props = {|
|
||||
documents: Document[],
|
||||
limit?: number,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
limit?: number;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
|
||||
return (
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
@@ -1,4 +1,3 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -6,31 +5,34 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import StarButton, { AnimatedStar } from "components/Star";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
highlight?: string | undefined;
|
||||
context?: string | undefined;
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
@@ -39,13 +41,19 @@ function replaceResultMarks(tag: string) {
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(props: Props) {
|
||||
function DocumentListItem(
|
||||
props: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const currentTeam = useCurrentTeam();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
const {
|
||||
document,
|
||||
showNestedDocuments,
|
||||
showParentDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
@@ -54,26 +62,35 @@ function DocumentListItem(props: Props) {
|
||||
highlight,
|
||||
context,
|
||||
} = props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = policies.abilities(currentTeam.id);
|
||||
const canCollection = policies.abilities(document.collectionId);
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
@@ -106,31 +123,35 @@ function DocumentListItem(props: Props) {
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showNestedDocuments={showNestedDocuments}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
{document.isTemplate && !document.isArchived && !document.isDeleted && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
can.createDocument &&
|
||||
canCollection.update && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
@@ -156,15 +177,21 @@ const Actions = styled(EventBoundary)`
|
||||
`};
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
const DocumentLink = styled(Link)<{
|
||||
$isStarred?: boolean;
|
||||
$menuOpen?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
width: calc(100vw - 8px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
@@ -174,7 +201,7 @@ const DocumentLink = styled(Link)`
|
||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:${hover},
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
@@ -208,8 +235,9 @@ const DocumentLink = styled(Link)`
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
@@ -240,4 +268,4 @@ const ResultContext = styled(Highlight)`
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default observer(DocumentListItem);
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
@@ -1,16 +1,18 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
import useStores from "hooks/useStores";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import DocumentTasks from "~/components/DocumentTasks";
|
||||
import Flex from "~/components/Flex";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
const Container = styled(Flex)`
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
@@ -18,33 +20,38 @@ const Container = styled(Flex)`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
const Viewed = styled.span`
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Modified = styled.span<{ highlight?: boolean }>`
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
type Props = {|
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showLastViewed?: boolean,
|
||||
showNestedDocuments?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
|};
|
||||
type Props = {
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showLastViewed?: boolean;
|
||||
showParentDocuments?: boolean;
|
||||
document: Document;
|
||||
children?: React.ReactNode;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
function DocumentMeta({
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
showNestedDocuments,
|
||||
showParentDocuments,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections, auth } = useStores();
|
||||
const { collections } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
@@ -55,6 +62,8 @@ function DocumentMeta({
|
||||
deletedAt,
|
||||
isDraft,
|
||||
lastViewedAt,
|
||||
isTasks,
|
||||
isTemplate,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
@@ -63,6 +72,8 @@ function DocumentMeta({
|
||||
return null;
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
@@ -97,57 +108,67 @@ function DocumentMeta({
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
|
||||
{t("updated")} <Time dateTime={updatedAt} addSuffix />
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
const canShowProgressBar = isTasks && !isTemplate;
|
||||
|
||||
const timeSinceNow = () => {
|
||||
if (isDraft || !showLastViewed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!lastViewedAt) {
|
||||
if (lastUpdatedByCurrentUser) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Viewed>
|
||||
• <Modified highlight>{t("Never viewed")}</Modified>
|
||||
</>
|
||||
</Viewed>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Viewed>
|
||||
• {t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</span>
|
||||
</Viewed>
|
||||
);
|
||||
};
|
||||
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
{t("in")}
|
||||
<strong>
|
||||
<Breadcrumb document={document} onlyText />
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showNestedDocuments && nestedDocumentsCount > 0 && (
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
· {nestedDocumentsCount}{" "}
|
||||
{t("nested document", { count: nestedDocumentsCount })}
|
||||
• {nestedDocumentsCount}{" "}
|
||||
{t("nested document", {
|
||||
count: nestedDocumentsCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{canShowProgressBar && (
|
||||
<>
|
||||
•
|
||||
<DocumentTasks document={document} />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
@@ -1,54 +0,0 @@
|
||||
// @flow
|
||||
import { useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import useStores from "../hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
||||
const { views } = useStores();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<>
|
||||
· Viewed by{" "}
|
||||
{onlyYou
|
||||
? "only you"
|
||||
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
|
||||
</>
|
||||
) : null}
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const Meta = styled(DocumentMeta)`
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default DocumentMetaWithViews;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Popover from "~/components/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
to?: string;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const { views } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!document.isDeleted) {
|
||||
views.fetchPage({
|
||||
documentId: document.id,
|
||||
});
|
||||
}
|
||||
}, [views, document.id, document.isDeleted]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 8,
|
||||
placement: "bottom",
|
||||
modal: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to} {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<>
|
||||
•
|
||||
<a {...props}>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
) : null}
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default DocumentMetaWithViews;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, TFunction } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import CircularProgressBar from "~/components/CircularProgressBar";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { bounceIn } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
function getMessage(t: TFunction, total: number, completed: number): string {
|
||||
if (completed === 0) {
|
||||
return t(`{{ total }} task`, {
|
||||
total,
|
||||
count: total,
|
||||
});
|
||||
} else if (completed === total) {
|
||||
return t(`{{ completed }} task done`, {
|
||||
completed,
|
||||
count: completed,
|
||||
});
|
||||
} else {
|
||||
return t(`{{ completed }} of {{ total }} tasks`, {
|
||||
total,
|
||||
completed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function DocumentTasks({ document }: Props) {
|
||||
const { tasks, tasksPercentage } = document;
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { completed, total } = tasks;
|
||||
const done = completed === total;
|
||||
const previousDone = usePrevious(done);
|
||||
const message = getMessage(t, total, completed);
|
||||
return (
|
||||
<>
|
||||
{completed === total ? (
|
||||
<Done
|
||||
color={theme.primary}
|
||||
size={20}
|
||||
$animated={done && previousDone === false}
|
||||
/>
|
||||
) : (
|
||||
<CircularProgressBar percentage={tasksPercentage} />
|
||||
)}
|
||||
{message}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Done = styled(DoneIcon)<{ $animated: boolean }>`
|
||||
margin: -1px;
|
||||
animation: ${(props) => (props.$animated ? bounceIn : "none")} 600ms;
|
||||
transform-origin: center center;
|
||||
`;
|
||||
|
||||
export default DocumentTasks;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
isOpen?: boolean;
|
||||
};
|
||||
|
||||
function DocumentViews({ document, isOpen }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { views, presence } = useStores();
|
||||
const documentPresence = presence.get(document.id);
|
||||
const documentPresenceArray = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
const presentIds = documentPresenceArray.map((p) => p.userId);
|
||||
const editingIds = documentPresenceArray
|
||||
.filter((p) => p.isEditing)
|
||||
.map((p) => p.userId);
|
||||
|
||||
// ensure currently present via websocket are always ordered first
|
||||
const documentViews = views.inDocument(document.id);
|
||||
const sortedViews = sortBy(
|
||||
documentViews,
|
||||
(view) => !presentIds.includes(view.user.id)
|
||||
);
|
||||
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
|
||||
sortedViews,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList
|
||||
items={users}
|
||||
renderItem={(item) => {
|
||||
const view = documentViews.find((v) => v.user.id === item.id);
|
||||
const isPresent = presentIds.includes(item.id);
|
||||
const isEditing = editingIds.includes(item.id);
|
||||
const subtitle = isPresent
|
||||
? isEditing
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: formatDistanceToNow(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
subtitle={subtitle}
|
||||
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
||||
border={false}
|
||||
compact
|
||||
small
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentViews);
|
||||
@@ -1,123 +0,0 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
let importingLock = false;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
ui: UiStore,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
location: Object,
|
||||
match: Match,
|
||||
history: RouterHistory,
|
||||
staticContext: Object,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DropToImport extends React.Component<Props> {
|
||||
@observable isImporting: boolean = false;
|
||||
|
||||
onDropAccepted = async (files = []) => {
|
||||
if (importingLock) return;
|
||||
|
||||
this.isImporting = true;
|
||||
importingLock = true;
|
||||
|
||||
try {
|
||||
let collectionId = this.props.collectionId;
|
||||
const documentId = this.props.documentId;
|
||||
const redirect = files.length === 1;
|
||||
|
||||
if (documentId && !collectionId) {
|
||||
const document = await this.props.documents.fetch(documentId);
|
||||
invariant(document, "Document not available");
|
||||
collectionId = document.collectionId;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await this.props.documents.import(
|
||||
file,
|
||||
documentId,
|
||||
collectionId,
|
||||
{ publish: true }
|
||||
);
|
||||
|
||||
if (redirect) {
|
||||
this.props.history.push(doc.url);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(`Could not import file. ${err.message}`, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
importingLock = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { documents } = this.props;
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={EMPTY_OBJECT}
|
||||
noClick
|
||||
multiple
|
||||
>
|
||||
{({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
}) => (
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
{...{ isDragActive }}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DropzoneContainer = styled("div")`
|
||||
border-radius: 4px;
|
||||
|
||||
${({ isDragActive, theme }) =>
|
||||
isDragActive &&
|
||||
css`
|
||||
background: ${theme.slateDark};
|
||||
a {
|
||||
color: ${theme.white} !important;
|
||||
}
|
||||
svg {
|
||||
fill: ${theme.white};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default inject("documents", "ui")(withRouter(DropToImport));
|
||||
@@ -1,242 +0,0 @@
|
||||
// @flow
|
||||
import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
export type Props = {|
|
||||
id?: string,
|
||||
value?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
scrollTo?: string,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
onFocus?: (event: SyntheticEvent<>) => any,
|
||||
onPublish?: (event: SyntheticEvent<>) => any,
|
||||
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
onCancel?: () => any,
|
||||
onChange?: (getValue: () => string) => any,
|
||||
onSearchLink?: (title: string) => any,
|
||||
onHoverLink?: (event: MouseEvent) => any,
|
||||
onCreateLink?: (title: string) => Promise<string>,
|
||||
onImageUploadStart?: () => any,
|
||||
onImageUploadStop?: () => any,
|
||||
|};
|
||||
|
||||
type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<any>,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
function Editor(props: PropsWithRef) {
|
||||
const { id, ui, history } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onUploadImage = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, { documentId: id });
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
const onClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
// probably absolute
|
||||
if (href[0] !== "/") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
}
|
||||
|
||||
history.push(navigateTo);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[history]
|
||||
);
|
||||
|
||||
const onShowToast = React.useCallback(
|
||||
(message: string) => {
|
||||
if (ui) {
|
||||
ui.showToast(message);
|
||||
}
|
||||
},
|
||||
[ui]
|
||||
);
|
||||
|
||||
const dictionary = React.useMemo(() => {
|
||||
return {
|
||||
addColumnAfter: t("Insert column after"),
|
||||
addColumnBefore: t("Insert column before"),
|
||||
addRowAfter: t("Insert row after"),
|
||||
addRowBefore: t("Insert row before"),
|
||||
alignCenter: t("Align center"),
|
||||
alignLeft: t("Align left"),
|
||||
alignRight: t("Align right"),
|
||||
bulletList: t("Bulleted list"),
|
||||
checkboxList: t("Todo list"),
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
createLink: t("Create link"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
createNewDoc: t("Create a new doc"),
|
||||
deleteColumn: t("Delete column"),
|
||||
deleteRow: t("Delete row"),
|
||||
deleteTable: t("Delete table"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
findOrCreateDoc: `${t("Find or create a doc")}…`,
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
h3: t("Small heading"),
|
||||
heading: t("Heading"),
|
||||
hr: t("Divider"),
|
||||
image: t("Image"),
|
||||
imageUploadError: t("Sorry, an error occurred uploading the image"),
|
||||
info: t("Info"),
|
||||
infoNotice: t("Info notice"),
|
||||
link: t("Link"),
|
||||
linkCopied: t("Link copied to clipboard"),
|
||||
mark: t("Highlight"),
|
||||
newLineEmpty: `${t("Type '/' to insert")}…`,
|
||||
newLineWithSlash: `${t("Keep typing to filter")}…`,
|
||||
noResults: t("No results"),
|
||||
openLink: t("Open link"),
|
||||
orderedList: t("Ordered list"),
|
||||
pasteLink: `${t("Paste a link")}…`,
|
||||
pasteLinkWithTitle: (service: string) =>
|
||||
t("Paste a {{service}} link…", { service }),
|
||||
placeholder: t("Placeholder"),
|
||||
quote: t("Quote"),
|
||||
removeLink: t("Remove link"),
|
||||
searchOrPasteLink: `${t("Search or paste a link")}…`,
|
||||
strikethrough: t("Strikethrough"),
|
||||
strong: t("Bold"),
|
||||
subheading: t("Subheading"),
|
||||
table: t("Table"),
|
||||
tip: t("Tip"),
|
||||
tipNotice: t("Tip notice"),
|
||||
warning: t("Warning"),
|
||||
warningNotice: t("Warning notice"),
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<StyledEditor
|
||||
ref={props.forwardedRef}
|
||||
uploadImage={onUploadImage}
|
||||
onClickLink={onClickLink}
|
||||
onShowToast={onShowToast}
|
||||
embeds={props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
||||
tooltip={EditorTooltip}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
flex-grow: ${(props) => (props.grow ? 1 : 0)};
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
& * {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.notice-block.tip,
|
||||
.notice-block.warning {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.heading-anchor {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.heading-name {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: relative;
|
||||
top: -60px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.heading-name:first-child {
|
||||
& + h1,
|
||||
& + h2,
|
||||
& + h3,
|
||||
& + h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
a {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)};
|
||||
text-decoration: none !important;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px solid ${(props) => props.theme.text};
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = ({ children, ...props }) => (
|
||||
<Tooltip offset="0, 16" delay={150} {...props}>
|
||||
<Span>{children}</Span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Span = styled.span`
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
|
||||
|
||||
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
|
||||
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
|
||||
));
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as React from "react";
|
||||
import { Optional } from "utility-types";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import { Props as EditorProps } from "~/editor";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { uploadFile } from "~/utils/uploadFile";
|
||||
import { isHash } from "~/utils/urls";
|
||||
|
||||
const SharedEditor = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "shared-editor" */
|
||||
"~/editor"
|
||||
)
|
||||
);
|
||||
|
||||
export type Props = Optional<
|
||||
EditorProps,
|
||||
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
grow?: boolean;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.Ref<any>) {
|
||||
const { id, shareId } = props;
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
|
||||
const onUploadImage = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
});
|
||||
return result.url;
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
const onClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (isHash(href)) {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
// probably absolute
|
||||
if (href[0] !== "/") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
}
|
||||
|
||||
if (shareId) {
|
||||
navigateTo = `/share/${shareId}${navigateTo}`;
|
||||
}
|
||||
|
||||
history.push(navigateTo);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[shareId]
|
||||
);
|
||||
|
||||
const onShowToast = React.useCallback(
|
||||
(message: string) => {
|
||||
showToast(message);
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<SharedEditor
|
||||
ref={ref}
|
||||
uploadImage={onUploadImage}
|
||||
onShowToast={onShowToast}
|
||||
embeds={embeds}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={onClickLink}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef(Editor);
|
||||
@@ -1,4 +1,3 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Empty = styled.p`
|
||||
@@ -1,117 +0,0 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import HelpText from "components/HelpText";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
reloadOnChunkMissing?: boolean,
|
||||
};
|
||||
|
||||
@observer
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
@observable error: ?Error;
|
||||
@observable showDetails: boolean = false;
|
||||
|
||||
componentDidCatch(error: Error, info: Object) {
|
||||
this.error = error;
|
||||
console.error(error);
|
||||
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
error.message &&
|
||||
error.message.match(/chunk/)
|
||||
) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
// bundle and the async-loaded editor JS bundle as the hash will change.
|
||||
window.location.reload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload(true);
|
||||
};
|
||||
|
||||
handleShowDetails = () => {
|
||||
this.showDetails = true;
|
||||
};
|
||||
|
||||
handleReportBug = () => {
|
||||
window.open(githubIssuesUrl());
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Module failed to load" />
|
||||
<h1>Loading Failed</h1>
|
||||
<HelpText>
|
||||
Sorry, part of the application failed to load. This may be because
|
||||
it was updated since you opened the tab or because of a failed
|
||||
network request. Please try reloading.
|
||||
</HelpText>
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>Reload</Button>
|
||||
</p>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Something Unexpected Happened" />
|
||||
<h1>Something Unexpected Happened</h1>
|
||||
<HelpText>
|
||||
Sorry, an unrecoverable error occurred
|
||||
{isReported && " – our engineers have been notified"}. Please try
|
||||
reloading the page, it may have been a temporary glitch.
|
||||
</HelpText>
|
||||
{this.showDetails && <Pre>{error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>Reload</Button>{" "}
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
Report a Bug…
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.handleShowDetails} neutral>
|
||||
Show Details…
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${(props) => props.theme.smoke};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,134 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { githubIssuesUrl } from "@shared/utils/urlHelpers";
|
||||
import Button from "~/components/Button";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
children: React.ReactNode;
|
||||
reloadOnChunkMissing?: boolean;
|
||||
};
|
||||
|
||||
@observer
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
@observable
|
||||
error: Error | null | undefined;
|
||||
|
||||
@observable
|
||||
showDetails = false;
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.error = error;
|
||||
console.error(error);
|
||||
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
error.message &&
|
||||
error.message.match(/chunk/)
|
||||
) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
// bundle and the async-loaded editor JS bundle as the hash will change.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleShowDetails = () => {
|
||||
this.showDetails = true;
|
||||
};
|
||||
|
||||
handleReportBug = () => {
|
||||
window.open(githubIssuesUrl());
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Module failed to load")} />
|
||||
<h1>
|
||||
<Trans>Loading Failed</Trans>
|
||||
</h1>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Sorry, part of the application failed to load. This may be
|
||||
because it was updated since you opened the tab or because of a
|
||||
failed network request. Please try reloading.
|
||||
</Trans>
|
||||
</Text>
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>{t("Reload")}</Button>
|
||||
</p>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Something Unexpected Happened")} />
|
||||
<h1>
|
||||
<Trans>Something Unexpected Happened</Trans>
|
||||
</h1>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
values={{
|
||||
notified: isReported
|
||||
? ` – ${t("our engineers have been notified")}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{this.showDetails && <Pre>{error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
<Trans>Report a Bug</Trans>…
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.handleShowDetails} neutral>
|
||||
<Trans>Show Detail</Trans>…
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
export default withTranslation()(ErrorBoundary);
|
||||
@@ -1,14 +1,12 @@
|
||||
// @flow
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
className?: string,
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function EventBoundary({ children, className }: Props) {
|
||||
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
|
||||
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
TrashIcon,
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
CheckboxIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
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 useStores from "~/hooks/useStores";
|
||||
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 { policies } = useStores();
|
||||
const location = useLocation();
|
||||
const can = policies.abilities(document.id);
|
||||
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;
|
||||
}
|
||||
|
||||
const isActive = location.pathname === to;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
small
|
||||
exact
|
||||
to={document.isDeleted ? undefined : to}
|
||||
title={
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
tooltipDelay={500}
|
||||
format="MMM do, h:mm a"
|
||||
relative={false}
|
||||
addSuffix
|
||||
/>
|
||||
}
|
||||
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
{meta}
|
||||
</Subtitle>
|
||||
}
|
||||
actions={
|
||||
isRevision && isActive && event.modelId && can.update ? (
|
||||
<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: auto;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:first-child:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${Actions} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default EventListItem;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user