mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
872 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c594423f | |||
| 2bf237d54b | |||
| 9a1c8f07d1 | |||
| 241cb11493 | |||
| 8195791bb2 | |||
| b037ae5dc1 | |||
| aeba8ce4eb | |||
| 3565e68725 | |||
| 429c5fba85 | |||
| 9495ddba25 | |||
| 486a60e97c | |||
| c687745263 | |||
| 1b92993b90 | |||
| 181a20a268 | |||
| f8ffa4e25a | |||
| 7e139ca8f7 | |||
| bb58db507d | |||
| 49bf86d6d9 | |||
| 286a15cf10 | |||
| f65469b777 | |||
| fe65a79d66 | |||
| a1d5ac0907 | |||
| 04eabe68a7 | |||
| 1c0c694c22 | |||
| 2ae74f2834 | |||
| 0f01fc5faa | |||
| 7f1322b7ba | |||
| 3c98133e24 | |||
| 088353d61f | |||
| 31180619e1 | |||
| 9fccc280d7 | |||
| c69b4efc34 | |||
| 61039e9d0d | |||
| 6d09122d56 | |||
| 5fb6097153 | |||
| ec17874568 | |||
| 40c3e9e85f | |||
| 9f739f3788 | |||
| 3cec6b4903 | |||
| ede7f2e3e6 | |||
| f6837b4742 | |||
| 1560e3c9f7 | |||
| ca74908dc5 | |||
| de7ec1119b | |||
| 2093b4297f | |||
| 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 | |||
| 3df82c500b | |||
| 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 | |||
| fb0b38fb71 | |||
| 8ff2f41068 | |||
| 334dce7984 | |||
| 61b303831f | |||
| a9d60d288e | |||
| 3f267d7745 | |||
| e845652cb8 | |||
| 7066a45323 | |||
| 654fdf1c7e | |||
| 2a5fd0b332 | |||
| 9ba63c6054 | |||
| 785e208c6c | |||
| 9d84652dff | |||
| ef6ce72cf5 | |||
| 7777cccf3b | |||
| 620e4942d8 | |||
| 91ee3e62f2 | |||
| eeb7650941 | |||
| ee57f1ccf5 | |||
| 32f0589190 | |||
| ce2b246e60 | |||
| ae13347d55 | |||
| 13205171d7 | |||
| a912ea24f6 | |||
| 6fa760688b | |||
| 2825df29de | |||
| 604e97a6ce | |||
| dc1bc44c8f | |||
| 2a55e78722 | |||
| eaf8dc5a3c | |||
| f89d5adc37 | |||
| 978a123122 | |||
| 96e65f495e | |||
| 4106f15450 | |||
| b3cd78c833 | |||
| 7b87fea4f4 | |||
| 7e9bcb0c37 | |||
| f6370ccf6d | |||
| 11e1108f4a | |||
| c9fdf48c33 | |||
| 6a206de6cd | |||
| c69b393776 | |||
| 6e9c456147 | |||
| 70626ffff0 | |||
| 993aad004e | |||
| 6fa9e700c8 | |||
| 836b2e310a | |||
| 24ecaa8ce4 | |||
| 40491fafe9 | |||
| 111212b038 | |||
| 774c3534d8 | |||
| 9759227d73 | |||
| f608872c11 | |||
| eff9544ef9 | |||
| 22fb464b87 | |||
| 3bace8c9e4 | |||
| 27fca28450 | |||
| afcce7a0ef | |||
| f33495dddc | |||
| 51b75fa09d | |||
| 522df125aa | |||
| 1fd2ec31fd | |||
| 1af00a0b3d | |||
| ab40545a01 | |||
| c8d305aeca | |||
| 68a65be135 | |||
| b4d307b3b4 | |||
| 03cb6d66e7 | |||
| 7b8cbc50d5 | |||
| f501da9c0f | |||
| 74a762a7c7 | |||
| 93ac9892d5 | |||
| e8b7782f5e | |||
| 47369dd968 | |||
| d258082c5f | |||
| c0bbae50c4 | |||
| ac082e4a5f | |||
| 7504d43452 | |||
| 5dba68dfd3 | |||
| 4b85603f30 | |||
| 34598b317d | |||
| cbfa25fa2f | |||
| 67a2246e1a | |||
| de7bf8c133 | |||
| 4fda50f4ad | |||
| f4c5cc054e | |||
| f799758a6f | |||
| 9df02d6fd4 | |||
| bb81aa0065 | |||
| 68bbd9fa34 | |||
| 308d4bd797 | |||
| 5329474c85 | |||
| d90af48741 | |||
| 611e9b97b3 | |||
| eda5adca2c | |||
| f0b361158e | |||
| f8ab793053 | |||
| 2cc45187e6 | |||
| ba61091c4c | |||
| 8dba32b5e0 | |||
| 40bd9aed0a | |||
| d4bb04e921 | |||
| 8a3a279c0e | |||
| 37f2cc8d55 | |||
| 89903b4bbe | |||
| 41be18e938 | |||
| caee7afde2 | |||
| d79933887d | |||
| 2787e56de3 | |||
| b932457fd3 | |||
| ea5d2ea9e0 | |||
| 6e9b4e8363 | |||
| 012e6b320e | |||
| c8cd7fcf4a | |||
| 7021c2a9e5 | |||
| 799e639439 | |||
| ba2552f69f | |||
| b6ab816bb3 | |||
| ac1120914a | |||
| a51af98d43 | |||
| ad7400a4f5 | |||
| ea57cef89c | |||
| 087ccdd825 | |||
| 7d44e1aeeb | |||
| 25d5ad8a7e | |||
| e34ba1457e | |||
| e966eb8c9a | |||
| 4684b3a3f3 | |||
| 47ce8afcc5 | |||
| decbe4f643 | |||
| 938f6ba8c5 | |||
| 7f5a7d7df7 | |||
| 117d278d16 | |||
| 40ca73e684 | |||
| b98e4bb1ff | |||
| 5012104a10 | |||
| 051ecab0fc | |||
| 3469b82beb | |||
| f2c3481670 | |||
| bc141dc40c | |||
| 99814f6e2f | |||
| 3737f0b42c | |||
| 9ef27cb436 | |||
| c9fa3f93f2 | |||
| 24ed96c9a5 | |||
| 956cf401bd | |||
| 7cb837f478 | |||
| a3209e9d23 | |||
| 29582a1bb1 | |||
| 4084c91769 | |||
| 6772f28226 | |||
| c6b110d339 | |||
| 0a43b50c66 | |||
| 4a82cb0658 | |||
| 2f7fca6106 | |||
| 8f83cfef25 | |||
| e2e66954b5 | |||
| 3dbe54ac1e | |||
| 50577f6f2f | |||
| 16d504703d | |||
| 173febcaa1 | |||
| f92f4cde7a | |||
| 23bec75bd0 | |||
| 4dd667f68b | |||
| 4b3cb77cc7 | |||
| 0e83d54f93 | |||
| d867d9fea5 | |||
| 28c8b8acfe | |||
| 51efffe2ce | |||
| 4e9ee7249f | |||
| 574fcc4bb3 | |||
| 5c3000d5cf | |||
| c0216cbb8d | |||
| cf12301077 | |||
| 1eb7da8742 | |||
| b3c548382f | |||
| 7ebac53b43 | |||
| 64428a6894 | |||
| d536af5269 | |||
| 1726a88a60 | |||
| 3fe807a10a | |||
| 72189e041b | |||
| bc156f4cc8 | |||
| 26693c60df | |||
| 9e1f31e14c | |||
| 63d926e196 | |||
| 3f9f1f0bed | |||
| b2bdc7f1d4 | |||
| 2e798c698d | |||
| aa59f5fe09 | |||
| ac2060b166 | |||
| 424c29536d | |||
| 6c1ecde4e7 | |||
| aa6fc45097 | |||
| 9478944718 | |||
| 9e1c5d1db3 | |||
| 474fbf07e6 | |||
| fe62048890 | |||
| 1851477290 | |||
| bde6f4b3c4 | |||
| 283b479689 | |||
| 183f06c2d1 | |||
| 21fff8d172 | |||
| 18e56aff65 | |||
| a97523a652 | |||
| 2316512a19 | |||
| 1285efc49a | |||
| 63c73c9a51 | |||
| 1b7fe0f7da | |||
| 6eda1cc0d3 | |||
| ac349b40f5 | |||
| 8bddc1b338 | |||
| 56d5f048f9 | |||
| 273d9c4680 | |||
| 44ca447185 | |||
| 6b511e4251 | |||
| de6ee91d96 | |||
| 18fac781a9 | |||
| 1ce01fa936 | |||
| 12a2e1c387 | |||
| 19ab32f551 | |||
| 7bdcba46b8 | |||
| 32d3053002 | |||
| 85dd54db3d | |||
| 5d0dd9b734 | |||
| 735d5cbf26 | |||
| a94012a03a | |||
| 80d74c9334 | |||
| 26e6db1afd | |||
| 5e7bbdc111 | |||
| 71e9860f88 | |||
| 26f4901547 | |||
| 4c56ed40f1 | |||
| 4e7a1cd121 | |||
| 14e0ed8108 | |||
| 0372ff2727 | |||
| 02e7e75cb9 | |||
| af73de4128 | |||
| 0125a5361d | |||
| fdaa36c9fd | |||
| 1b6a986986 | |||
| 3d09c8f655 | |||
| 7735aa12d7 | |||
| 7ac724909d | |||
| abd3a1ee12 | |||
| 9bfc0cacae | |||
| bd80e8384a | |||
| bfdfa3ee4b | |||
| dba5dd14e7 | |||
| a9c05adc3c | |||
| 34bdc88003 | |||
| df7b9f3e88 | |||
| b78e2f1e05 | |||
| 15337b5bdf | |||
| 4103f53f2a | |||
| 758fcc1759 | |||
| b3549637fe | |||
| 7bee60a337 | |||
| 38a005ed8a | |||
| 71b7ef1186 | |||
| 3af1a80615 | |||
| 4044818daa | |||
| 428171a1ec | |||
| 9c3195ef25 | |||
| a6dc708fc0 | |||
| 9c8f125668 | |||
| f348db048e | |||
| 0b8eb326ab | |||
| 1da1f3d6e8 | |||
| 7def0dfab1 | |||
| 96987d2091 | |||
| 6bb32c253b | |||
| 1d5f735032 | |||
| f3e3651222 | |||
| b3d9478486 | |||
| f26aeca46a | |||
| f1a95e5e79 | |||
| 6f1f855083 | |||
| 40d52e9a78 | |||
| a2f2971fec | |||
| d89808ce9d | |||
| a43cc9c5a9 | |||
| bb7fcd1b67 | |||
| c1957025ec | |||
| 98626ebbaf | |||
| 0fa8a6ed2e | |||
| fa96891c8e | |||
| 9aa81dcf82 | |||
| c04d5bdfb0 | |||
| ea69d09562 | |||
| c8ff5cf221 | |||
| 5638f7a687 | |||
| 1293f52552 | |||
| 86812cfe76 | |||
| d9b7384853 | |||
| 292afd774d | |||
| d3d286b1be | |||
| 26b9566b96 | |||
| d487da8f15 | |||
| 4ffc04bc5d | |||
| 68148bd4d8 | |||
| 881105992e | |||
| 2c1a111dee | |||
| e67d319e2b | |||
| 85f7e03921 | |||
| e30adbaac2 | |||
| ac8f0ebaac | |||
| b3b71d2dc7 | |||
| ab3613af48 | |||
| 3940f1a108 | |||
| 142e7da6a5 | |||
| 021de66f7a | |||
| 55858d5d7d | |||
| 93d3582ac7 | |||
| 0b2107c1ee | |||
| f8a167fd4b | |||
| 608be3deef | |||
| 56551d1ab3 | |||
| 450d6b7e42 | |||
| fc98cf78e6 | |||
| d9aa53a094 | |||
| ffab4fbf76 | |||
| 2161fba1dd | |||
| cd2cdd025c | |||
| d5f5319f80 | |||
| c298c73240 | |||
| 38d1831259 | |||
| 9c9b95741c | |||
| cc8db7e991 | |||
| c5b7d9be13 | |||
| be2e46b5d2 | |||
| 4b2a766357 | |||
| f264d67862 | |||
| b1648ac2aa | |||
| 25423d8c85 | |||
| e7ab2939d4 | |||
| ceeac9b982 | |||
| 5de2f969e3 | |||
| b54901d50c | |||
| 4de3f69474 | |||
| c5de2da115 | |||
| 709c3e78bd | |||
| acb04fdf1a | |||
| f13696dd2a | |||
| d6f245d67e | |||
| f2abf38fe4 | |||
| f0712e22d8 | |||
| e7e289d9fa | |||
| 713187cfb4 | |||
| 11d3a5c9b9 | |||
| cf1e506009 | |||
| 6b6d67beb6 | |||
| a98e8ad8df | |||
| 9049785d98 | |||
| e8648d4611 | |||
| dd7436f78c | |||
| b93a397ab3 | |||
| 206160582e | |||
| 4bf5926ee3 | |||
| 82433e02a0 | |||
| 637a9b5cf9 | |||
| 95b91c466a | |||
| 4edf90a184 | |||
| 31522b0d6f | |||
| 759d4a5ac2 | |||
| dd0d51dd9d | |||
| 8550116c6b | |||
| 3c7dc93982 | |||
| 0aa338cccc | |||
| 8f41895e66 | |||
| de8ac4acf5 | |||
| de59147418 | |||
| cf522cc85f | |||
| 8c7200fa87 | |||
| f2310be173 | |||
| 29f4dc9331 | |||
| 03b6dd62a8 | |||
| 7f0c608dbb | |||
| c52fbb944e | |||
| e22e952606 | |||
| 197cdff6c3 | |||
| 85d09b2351 | |||
| 69611638b9 | |||
| e117d5f103 | |||
| 03db975217 | |||
| 76279902f9 | |||
| a304e91ffc | |||
| 9b5573c5e2 | |||
| ec61efa12b | |||
| b01778a39f | |||
| 5aa092853b | |||
| 1fa3db4bdc | |||
| 6a9f74e6cc | |||
| e8719340d1 | |||
| 70838918c3 | |||
| ec38f5d79c | |||
| 179176c312 | |||
| c446a91f7d | |||
| 05f48f054b | |||
| ec55299c8b | |||
| 26c574ab58 | |||
| 6dd6768f07 | |||
| 0555fd2caa | |||
| d885252fb0 | |||
| df9b0bcf91 | |||
| 31910f1628 | |||
| 14cb3a36c1 | |||
| d3350c20b6 | |||
| 174acfac32 | |||
| 9ef4e2b437 | |||
| 8088da8cf3 | |||
| 221ee48429 | |||
| ffe8c046ef | |||
| dbe8a10702 | |||
| 11f7e3a060 | |||
| 0f41a04e49 | |||
| d055021ad4 | |||
| 810dc5a061 | |||
| 7abe375b3e | |||
| 63371d8f5b | |||
| 6e61df0729 | |||
| 5ddc4000d0 | |||
| 48b61559cc | |||
| 0cac5cfe51 | |||
| e9ce80a3aa | |||
| 07d488c826 | |||
| e2bd03494d | |||
| ead55442e0 | |||
| 449dc55aaa | |||
| e312b264a6 | |||
| 68dcb4de5f | |||
| d2b9a5c03f | |||
| 1b023fb6d7 | |||
| afe4553a7e | |||
| 139e2e29d7 | |||
| 638418432a | |||
| c6d2467fae | |||
| e9387db895 | |||
| 065d04ec98 | |||
| 869fc086d6 | |||
| 59c24aba7c | |||
| bc4806ac30 | |||
| 169ad5b025 | |||
| 0b33b5bc05 | |||
| 109efcaa27 | |||
| 2cc6d7add8 | |||
| 003d82fe8a | |||
| f75a07cb0d | |||
| 0d6720e499 | |||
| a97a1df5f1 | |||
| 822395c265 | |||
| 96d6e9b85e | |||
| 70e1194f90 | |||
| 710fcc697c | |||
| 58f9e95d2f | |||
| bc128359ab | |||
| af09713c8c | |||
| 35052ef38f | |||
| ec3adc6d1c | |||
| 67981a351e | |||
| 24448c7504 | |||
| d5b5d4fc27 | |||
| d8603cc961 | |||
| 943a290b83 | |||
| f4a4f034cf | |||
| a53934a5c9 | |||
| e859e3b9e0 | |||
| b51d818db3 | |||
| bfea742650 | |||
| 9951cbf194 | |||
| 5cb04d7ac1 | |||
| 75561079eb | |||
| d2c7f3f166 | |||
| a077766bff | |||
| a25e03d7cd | |||
| 2953d09ee1 | |||
| 28d15b1573 | |||
| 18108cb359 | |||
| 5dfa6a71a4 | |||
| 35270cd104 | |||
| 19d01ed575 | |||
| 2b7639c903 | |||
| c8269ca134 | |||
| da9fc5bfdf | |||
| efcfda8398 | |||
| ce2f69342c | |||
| 9be5597f4b | |||
| 26f6961c82 | |||
| bf5f83e97d | |||
| 8db0260a1a | |||
| edbae275ae | |||
| f43deb7940 | |||
| 2a3b9e2104 | |||
| 64c3ff8d6b | |||
| 4f7e7ec853 | |||
| d864e228e7 | |||
| f3ea02fdd0 | |||
| ed096013e4 | |||
| 2e376b32ec | |||
| f352b35e13 | |||
| 0f8d503df8 | |||
| 5010b08e83 | |||
| 933bbdfb84 | |||
| d25a9d56dc | |||
| 1d8c3f3faf | |||
| d7766280a9 | |||
| ae5eff2914 | |||
| b444874944 | |||
| 20efa82ad9 | |||
| bd9d4b3d0d | |||
| 4b4f4fd188 | |||
| 33815639f2 | |||
| 05e24df226 | |||
| d2d9164fa1 | |||
| f2eb395e8d | |||
| 133cb56cca | |||
| e752dba566 | |||
| c1a141d99f | |||
| ccedea55d6 | |||
| c929f83813 | |||
| 1b25d12e2e | |||
| 0c254285a1 | |||
| 8b274c3713 |
@@ -1,18 +1,29 @@
|
||||
{
|
||||
"presets": ["react", "env"],
|
||||
"presets": [
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-flow",
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"corejs": {
|
||||
"version": "3",
|
||||
"proposals": true
|
||||
},
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"lodash",
|
||||
"styled-components",
|
||||
"transform-decorators-legacy",
|
||||
"transform-es2015-destructuring",
|
||||
"transform-object-rest-spread",
|
||||
"transform-regenerator",
|
||||
"transform-class-properties",
|
||||
"syntax-dynamic-import"
|
||||
],
|
||||
"env": {
|
||||
"development": {
|
||||
"presets": ["react-hmre"]
|
||||
}
|
||||
}
|
||||
}
|
||||
[
|
||||
"@babel/plugin-proposal-decorators",
|
||||
{
|
||||
"legacy": true
|
||||
}
|
||||
],
|
||||
"@babel/plugin-transform-destructuring",
|
||||
"@babel/plugin-transform-regenerator",
|
||||
"transform-class-properties"
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@ jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: circleci/node:12
|
||||
- image: circleci/node:14
|
||||
- image: circleci/redis:latest
|
||||
- image: circleci/postgres:9.6.5-alpine-ram
|
||||
environment:
|
||||
@@ -29,12 +29,15 @@ jobs:
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: flow
|
||||
command: yarn flow
|
||||
command: yarn flow check --max-workers 4
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test
|
||||
- run:
|
||||
name: build-webpack
|
||||
command: yarn build:webpack
|
||||
@@ -0,0 +1,19 @@
|
||||
__mocks__
|
||||
.git
|
||||
.vscode
|
||||
.github
|
||||
.circleci
|
||||
.DS_Store
|
||||
.env*
|
||||
.eslint*
|
||||
.flowconfig
|
||||
.log
|
||||
Makefile
|
||||
Procfile
|
||||
app.json
|
||||
build
|
||||
docker-compose.yml
|
||||
fakes3
|
||||
flow-typed
|
||||
node_modules
|
||||
setupJest.js
|
||||
+111
-35
@@ -1,63 +1,139 @@
|
||||
# Copy this file to .env, remove this comment and change the keys. For development
|
||||
# with docker this should mostly work out of the box other than setting the Slack
|
||||
# keys (for auth) and the SECRET_KEY.
|
||||
#
|
||||
# Please use `openssl rand -hex 32` to create SECRET_KEY
|
||||
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
|
||||
# file to .env or set the variables in your local environment manually. For
|
||||
# development with docker this should mostly work out of the box other than
|
||||
# setting the Slack keys and the SECRET_KEY.
|
||||
|
||||
|
||||
|
||||
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
# Generate a 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
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
# 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.
|
||||
|
||||
DEPLOYMENT=self
|
||||
ENABLE_UPDATES=true
|
||||
WEBSOCKETS_ENABLED=true
|
||||
DEBUG=cache,presenters,events
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
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)
|
||||
# A more detailed guide on setting up S3 is available here:
|
||||
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
|
||||
#
|
||||
AWS_ACCESS_KEY_ID=get_a_key_from_aws
|
||||
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
|
||||
AWS_REGION=xx-xxxx-x
|
||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
# uploaded s3 objects permission level, default is private
|
||||
# set to "public-read" to allow public access
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
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=
|
||||
|
||||
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
|
||||
# This will cause paths to javascript, stylesheets, and images to be updated to
|
||||
# the hostname defined in CDN_URL. In your CDN configuration the origin server
|
||||
# should be set to the same as URL.
|
||||
CDN_URL=
|
||||
|
||||
# Auto-redirect to https in production. The default is true but you may set to
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# Have the installation check for updates by sending anonymized statistics to
|
||||
# the maintainers
|
||||
ENABLE_UPDATES=true
|
||||
|
||||
# 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 may enable or disable debugging categories to increase the noisiness of
|
||||
# logs. The default is a good balance
|
||||
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,services
|
||||
|
||||
# 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
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
@@ -4,13 +4,57 @@
|
||||
"react-app",
|
||||
"plugin:import/errors",
|
||||
"plugin:import/warnings",
|
||||
"plugin:flowtype/recommended"
|
||||
"plugin:flowtype/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"prettier",
|
||||
"flowtype"
|
||||
],
|
||||
"plugins": ["prettier", "flowtype"],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"no-unused-vars": 2,
|
||||
"no-mixed-operators": "off",
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"alphabetize": {
|
||||
"order": "asc"
|
||||
},
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "shared/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "stores",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "stores/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "models/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "scenes/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "components/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"flowtype/require-valid-file-annotation": [
|
||||
2,
|
||||
"always",
|
||||
@@ -18,14 +62,19 @@
|
||||
"annotationStyle": "line"
|
||||
}
|
||||
],
|
||||
"flowtype/space-after-type-colon": [2, "always"],
|
||||
"flowtype/space-before-type-colon": [2, "never"],
|
||||
"flowtype/space-after-type-colon": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"flowtype/space-before-type-colon": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -33,12 +82,14 @@
|
||||
"react": {
|
||||
"createClass": "createReactClass",
|
||||
"pragma": "React",
|
||||
"version": "detect",
|
||||
"flowVersion": "0.86"
|
||||
"version": "detect"
|
||||
},
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"paths": ["app", "."]
|
||||
"paths": [
|
||||
"app",
|
||||
"."
|
||||
]
|
||||
}
|
||||
},
|
||||
"flowtype": {
|
||||
@@ -49,12 +100,6 @@
|
||||
"jest": true
|
||||
},
|
||||
"globals": {
|
||||
"__DEV__": true,
|
||||
"SLACK_KEY": true,
|
||||
"DEPLOYMENT": true,
|
||||
"BASE_URL": true,
|
||||
"SENTRY_DSN": true,
|
||||
"afterAll": true,
|
||||
"Sentry": true
|
||||
"EDITOR_VERSION": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
.*/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/.*
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
[options]
|
||||
emoji=true
|
||||
sharedmemory.heap_size=3221225472
|
||||
|
||||
module.system.node.resolve_dirname=node_modules
|
||||
module.system.node.resolve_dirname=app
|
||||
@@ -31,6 +33,7 @@ module.file_ext=.json
|
||||
esproposal.decorators=ignore
|
||||
esproposal.class_static_fields=enable
|
||||
esproposal.class_instance_fields=enable
|
||||
esproposal.optional_chaining=enable
|
||||
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [outline]
|
||||
@@ -0,0 +1,10 @@
|
||||
# Set to true to add reviewers to pull requests
|
||||
addReviewers: true
|
||||
|
||||
# A list of reviewers to be added to pull requests (GitHub user name)
|
||||
reviewers:
|
||||
- tommoor
|
||||
|
||||
# A list of keywords to be skipped the process that add reviewers if pull requests include it
|
||||
skipKeywords:
|
||||
- wip
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
daysUntilStale: 90
|
||||
|
||||
# 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
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
dist
|
||||
build
|
||||
node_modules/*
|
||||
server/scripts
|
||||
.env
|
||||
.log
|
||||
npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
fakes3/*
|
||||
.idea
|
||||
|
||||
Vendored
+5
-1
@@ -1,3 +1,7 @@
|
||||
{
|
||||
"javascript.validate.enable": false
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.validate.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
|
||||
# Architecture
|
||||
|
||||
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
|
||||
|
||||
## Frontend
|
||||
|
||||
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [MobX](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
|
||||
|
||||
> Important Note: The Outline editor is built on [Prosemirror](https://github.com/prosemirror) and managed in a separate open source repository to encourage re-use: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor).
|
||||
|
||||
```
|
||||
app
|
||||
├── components - React components reusable across scenes
|
||||
├── embeds - Embed definitions that represent rich interactive embeds in the editor
|
||||
├── hooks - Reusable React hooks
|
||||
├── menus - Context menus, often appear in multiple places in the UI
|
||||
├── models - State models using MobX observables
|
||||
├── routes - Route definitions, note that chunks are async loaded with suspense
|
||||
├── scenes - A scene represents a full-page view that contains several components
|
||||
├── stores - Collections of models and associated fetch logic
|
||||
├── types - Flow types
|
||||
└── utils - Utility methods specific to the frontend
|
||||
```
|
||||
|
||||
## Backend
|
||||
|
||||
The API server is driven by [Koa](http://koajs.com/), it uses [Sequelize](http://docs.sequelizejs.com/) as the ORM and Redis with [Bull](https://github.com/OptimalBits/bull) for queues and async event management. Authorization logic
|
||||
is contained in [cancan](https://www.npmjs.com/package/cancan) policies under the "policies" directory.
|
||||
|
||||
Interested in more documentation on the API routes? Check out the [API documentation](https://getoutline.com/developers).
|
||||
|
||||
```
|
||||
server
|
||||
├── api - All API routes are contained within here
|
||||
│ └── middlewares - Koa middlewares specific to the API
|
||||
├── auth - Authentication logic
|
||||
│ └── providers - Authentication providers export passport.js strategies and config
|
||||
├── commands - We are gradually moving to the command pattern for new write logic
|
||||
├── config - Database configuration
|
||||
├── emails - Transactional email templates
|
||||
│ └── components - Shared React components for email templates
|
||||
├── middlewares - Koa middlewares
|
||||
├── migrations - Database migrations
|
||||
├── models - Sequelize models
|
||||
├── onboarding - Markdown templates for onboarding documents
|
||||
├── policies - Authorization logic based on cancan
|
||||
├── presenters - JSON presenters for database models, the interface between backend -> frontend
|
||||
├── services - Service definitions are triggered for events and perform async jobs
|
||||
├── static - Static assets
|
||||
├── test - Test helpers and fixtures, tests themselves are colocated
|
||||
└── utils - Utility methods specific to the backend
|
||||
```
|
||||
|
||||
## Shared
|
||||
|
||||
Where logic is shared between the client and server it is placed in this directory. This is generally
|
||||
small utilities.
|
||||
|
||||
```
|
||||
shared
|
||||
├── i18n - Internationalization confiuration
|
||||
│ └── locales - Language specific translation files
|
||||
├── styles - Styles, colors and other global aesthetics
|
||||
├── utils - Shared utility methods
|
||||
└── constants - Shared constants
|
||||
```
|
||||
+14
-7
@@ -1,16 +1,23 @@
|
||||
FROM node:12-alpine
|
||||
FROM node:14-alpine
|
||||
|
||||
ENV PATH /opt/outline/node_modules/.bin:/opt/node_modules/.bin:$PATH
|
||||
ENV NODE_PATH /opt/outline/node_modules:/opt/node_modules
|
||||
ENV APP_PATH /opt/outline
|
||||
RUN mkdir -p $APP_PATH
|
||||
|
||||
WORKDIR $APP_PATH
|
||||
COPY . $APP_PATH
|
||||
|
||||
RUN yarn install --pure-lockfile
|
||||
RUN cp -r /opt/outline/node_modules /opt/node_modules
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
CMD yarn build && yarn start
|
||||
RUN yarn --pure-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn build && \
|
||||
yarn --production --ignore-scripts --prefer-offline && \
|
||||
rm -rf shared && \
|
||||
rm -rf app
|
||||
|
||||
ENV NODE_ENV production
|
||||
CMD yarn start
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.42.0
|
||||
Licensed Work: Outline 0.55.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2023-04-01
|
||||
Change Date: 2024-04-22
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -8,10 +8,18 @@ build:
|
||||
docker-compose build --pull outline
|
||||
|
||||
test:
|
||||
docker-compose run --rm outline yarn test
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
yarn test
|
||||
|
||||
watch:
|
||||
docker-compose run --rm outline yarn test:watch
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
yarn test:watch
|
||||
|
||||
destroy:
|
||||
docker-compose stop
|
||||
|
||||
@@ -6,37 +6,88 @@
|
||||
<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>
|
||||
<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>
|
||||
</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:
|
||||
|
||||
- Node.js >= 12
|
||||
- Postgres >=9.5
|
||||
- Redis >= 4
|
||||
- AWS S3 storage bucket for media and other attachments
|
||||
- [Node.js](https://nodejs.org/) >= 12
|
||||
- [Yarn](https://yarnpkg.com)
|
||||
- [Postgres](https://www.postgresql.org/download/) >=9.5
|
||||
- [Redis](https://redis.io/) >= 4
|
||||
- AWS S3 bucket or compatible API for file storage
|
||||
- Slack or Google developer application for authentication
|
||||
|
||||
|
||||
### Development
|
||||
## Self-Hosted Production
|
||||
|
||||
In development you can quickly get an environment running using Docker by following these steps:
|
||||
### Docker
|
||||
|
||||
For a manual self-hosted production installation these are the recommended steps:
|
||||
|
||||
1. First setup Redis and Postgres servers, this is outside the scope of the guide.
|
||||
1. Download the latest official Docker image, new releases are available around the middle of every month:
|
||||
|
||||
`docker pull outlinewiki/outline`
|
||||
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
|
||||
|
||||
`docker run --env-file=.env outlinewiki/outline`
|
||||
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
|
||||
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
|
||||
|
||||
`docker run --rm outlinewiki/outline yarn db:migrate`
|
||||
1. Start the container:
|
||||
|
||||
`docker run outlinewiki/outline`
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
|
||||
> Port number can be changed using the `PORT` environment variable
|
||||
|
||||
1. (Optional) You can add an `nginx` or other reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
|
||||
|
||||
### Terraform
|
||||
|
||||
Alternatively a community member maintains a script to deploy Outline on Google Cloud Platform with [Terraform & Ansible](https://github.com/rjsgn/outline-terraform-ansible).
|
||||
|
||||
### Upgrading
|
||||
|
||||
#### Docker
|
||||
|
||||
If you're running Outline with Docker you'll need to run migrations within the docker container after updating the image. The command will be something like:
|
||||
|
||||
```shell
|
||||
docker run --rm outlinewiki/outline:latest yarn db:migrate
|
||||
```
|
||||
|
||||
#### Git
|
||||
|
||||
If you're running Outline by cloning this repository, run the following command to upgrade:
|
||||
|
||||
```shell
|
||||
yarn run upgrade
|
||||
```
|
||||
|
||||
|
||||
## Local Development
|
||||
|
||||
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
|
||||
|
||||
1. Install these dependencies if you don't already have them
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. [Docker for Desktop](https://www.docker.com)
|
||||
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
|
||||
1. [Yarn](https://yarnpkg.com)
|
||||
1. Clone this repo
|
||||
1. Register a Slack app at https://api.slack.com/apps
|
||||
1. Copy the file `.env.sample` to `.env`
|
||||
@@ -45,45 +96,64 @@ In development you can quickly get an environment running using Docker by follow
|
||||
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. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
|
||||
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
### Production
|
||||
# Contributing
|
||||
|
||||
For a self-hosted production installation there is more flexibility, but these are the suggested steps:
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
1. Clone this repo and install dependencies with `yarn` or `npm install`
|
||||
Before submitting a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in the [Discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of code being accepted.
|
||||
|
||||
> Requires [Node.js](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
1. Build the web app with `yarn build:webpack` or `npm run build:webpack`
|
||||
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
|
||||
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
|
||||
1. `URL` (the public facing URL of your installation)
|
||||
1. `AWS_` (all of the keys beginning with AWS)
|
||||
1. Migrate database schema with `yarn sequelize:migrate` or `npm run sequelize:migrate `
|
||||
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start index.js --name outline `
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
|
||||
> Port number can be changed in the `.env` file
|
||||
|
||||
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
|
||||
* [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
|
||||
|
||||
|
||||
## Development
|
||||
## Architecture
|
||||
|
||||
### Server
|
||||
If you're interested in contributing or learning more about the Outline codebase
|
||||
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
|
||||
|
||||
|
||||
## Debugging
|
||||
|
||||
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
|
||||
|
||||
```
|
||||
DEBUG=sql,cache,presenters,events,logistics,emails,mailer
|
||||
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
|
||||
|
||||
```shell
|
||||
# To run all tests
|
||||
make test
|
||||
|
||||
# To run backend tests in watch mode
|
||||
make watch
|
||||
```
|
||||
|
||||
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 frontend tests
|
||||
yarn test:app
|
||||
```
|
||||
|
||||
## Migrations
|
||||
@@ -101,69 +171,6 @@ 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/pages` - Server-side rendered public pages
|
||||
- `server/policies` - Authorization logic
|
||||
- `server/presenters` - API responses for database models
|
||||
- `server/test` - Test helps and support
|
||||
- `server/utils` - Utility methods
|
||||
- `shared` - Code shared between frontend and backend applications
|
||||
|
||||
## Tests
|
||||
|
||||
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
|
||||
|
||||
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
|
||||
|
||||
```shell
|
||||
# To run all tests
|
||||
yarn test
|
||||
|
||||
# To run backend tests
|
||||
yarn test:server
|
||||
|
||||
# To run frontend tests
|
||||
yarn test:app
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
|
||||
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 our [Spectrum community](https://spectrum.chat/outline). 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!
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
* 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
|
||||
* Helping others on Spectrum
|
||||
|
||||
## License
|
||||
|
||||
Outline is [BSL 1.1 licensed](https://github.com/outline/outline/blob/master/LICENSE).
|
||||
Outline is [BSL 1.1 licensed](LICENSE).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Translation
|
||||
|
||||
Outline is localized through community contributions. The text in Outline's user interface is in American English by default, we're very thankful for all help that the community provides bringing the app to different languages.
|
||||
|
||||
## Externalizing strings
|
||||
|
||||
Before a string can be translated, it must be externalized. This is the process where English strings in the source code are wrapped in a function that retrieves the translated string for the user’s language.
|
||||
|
||||
For externalization we use [react-i18next](https://react.i18next.com/), this provides the hooks [useTranslation](https://react.i18next.com/latest/usetranslation-hook) and the [Trans](https://react.i18next.com/latest/trans-component) component for wrapping English text.
|
||||
|
||||
PR's are accepted for wrapping English strings in the codebase that were not previously externalized.
|
||||
|
||||
## Translating strings
|
||||
|
||||
To manage the translation process we use [CrowdIn](https://translate.getoutline.com/), it keeps track of which strings in which languages still need translating, synchronizes with the codebase automatically, and provides a great editor interface.
|
||||
|
||||
You'll need to create a free account to use CrowdIn. Once you have joined, you can provide translations by following these steps:
|
||||
|
||||
1. Select the language for which you want to contribute (or vote for) a translation (below the language you can see the progress of the translation)
|
||||

|
||||
|
||||
2. Please choose the translation.json file from your desired language
|
||||
|
||||
3. Once a file is selected, all the strings associated with the version are displayed on the left side. To display the untranslated strings first, select the filter icon next to the search bar and select “All, Untranslated First”.The red square next to an English string shows that a string has not been translated yet. To provide a translation, select a string on the left side, provide a translation in the target language in the text box in the right side (singular and plural) and press the save button. As soon as a translation has been provided by another user (green square next to string), you can also vote on a translation provided by another user. The translation with the most votes is used unless a different translation has been approved by a proof reader. 
|
||||
|
||||
## Proofreading
|
||||
|
||||
Once a translation has been provided, a proof reader can approve the translation and mark it for use in Outline.
|
||||
|
||||
If you are interested in becoming a proof reader, please contact one of the project managers in the Outline CrowdIn project or contact [@tommoor](https://github.com/tommoor). Similarly, if your language is not listed in the list of CrowdIn languages, please contact our project managers or [send us an email](https://www.getoutline.com/contact) so we can add your language.
|
||||
|
||||
## Release
|
||||
|
||||
Updated translations are automatically PR'd against the codebase by a bot and will be merged regularly so that new translations appear in the next release of Outline.
|
||||
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
export default class Queue {
|
||||
name;
|
||||
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
process = (fn) => {
|
||||
console.log(`Registered function ${this.name}`);
|
||||
this.processFn = fn;
|
||||
};
|
||||
|
||||
add = (data) => {
|
||||
console.log(`Running ${this.name}`);
|
||||
return this.processFn({ data });
|
||||
};
|
||||
}
|
||||
@@ -30,25 +30,19 @@
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": {
|
||||
"value": "production",
|
||||
"required": true
|
||||
},
|
||||
"SECRET_KEY": {
|
||||
"description": "A secret key",
|
||||
"generator": "secret",
|
||||
"required": true
|
||||
},
|
||||
"DEPLOYMENT": {
|
||||
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
|
||||
"value": "self",
|
||||
"required": true
|
||||
},
|
||||
"ENABLE_UPDATES": {
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"WEBSOCKETS_ENABLED": {
|
||||
"value": "true",
|
||||
"required": true,
|
||||
"description": "Allow realtime data to be pushed to clients over websockets"
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com",
|
||||
"required": true
|
||||
@@ -61,7 +55,7 @@
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ALLOWED_DOMAINS": {
|
||||
"ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
|
||||
"required": false
|
||||
},
|
||||
@@ -102,6 +96,11 @@
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_FORCE_PATH_STYLE": {
|
||||
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
|
||||
"value": "true",
|
||||
"required": false
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"value": "us-east-1",
|
||||
"description": "Region in which the above S3 bucket exists",
|
||||
@@ -136,6 +135,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
|
||||
@@ -149,4 +157,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"testURL": "http://localhost",
|
||||
"verbose": false,
|
||||
"rootDir": "..",
|
||||
"roots": [
|
||||
"<rootDir>/app",
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"jsx",
|
||||
"json"
|
||||
],
|
||||
"moduleDirectories": [
|
||||
"node_modules"
|
||||
],
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./app/test/setup.js"
|
||||
]
|
||||
}
|
||||
+10
-11
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
export const Action = styled(Flex)`
|
||||
justify-content: center;
|
||||
@@ -11,9 +11,8 @@ export const Action = styled(Flex)`
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
|
||||
a {
|
||||
color: ${props => props.theme.text};
|
||||
height: 24px;
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -22,7 +21,7 @@ export const Separator = styled.div`
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: ${props => props.theme.divider};
|
||||
background: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
@@ -31,16 +30,16 @@ const Actions = styled(Flex)`
|
||||
right: 0;
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${props => props.theme.background};
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
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;
|
||||
}
|
||||
|
||||
${breakpoint('tablet')`
|
||||
${breakpoint("tablet")`
|
||||
left: auto;
|
||||
padding: 24px;
|
||||
`};
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
type?: 'info' | 'success' | 'warning' | 'danger' | 'offline',
|
||||
};
|
||||
|
||||
@observer
|
||||
class Alert extends React.Component<Props> {
|
||||
defaultProps = {
|
||||
type: 'info',
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container align="center" justify="center" type={this.props.type}>
|
||||
{this.props.children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
height: $headerHeight;
|
||||
color: ${props => props.theme.white};
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
|
||||
background-color: ${({ theme, type }) => theme.color[type]};
|
||||
`;
|
||||
|
||||
export default Alert;
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
/* global ga */
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
children?: React.Node,
|
||||
@@ -8,26 +9,33 @@ type Props = {
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!process.env.GOOGLE_ANALYTICS_ID) return;
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
window.ga ||
|
||||
function() {
|
||||
function () {
|
||||
// $FlowIssue
|
||||
(ga.q = ga.q || []).push(arguments);
|
||||
};
|
||||
|
||||
// $FlowIssue
|
||||
ga.l = +new Date();
|
||||
ga('create', process.env.GOOGLE_ANALYTICS_ID, 'auto');
|
||||
ga('set', { dimension1: 'true' });
|
||||
ga('send', 'pageview');
|
||||
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
|
||||
ga("set", { dimension1: "true" });
|
||||
ga("send", "pageview");
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.google-analytics.com/analytics.js';
|
||||
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,23 @@
|
||||
// @flow
|
||||
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,5 +1,5 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
className?: string,
|
||||
};
|
||||
|
||||
function GoogleLogo({ size = 34, fill = '#FFF', className }: Props) {
|
||||
function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
@@ -0,0 +1,44 @@
|
||||
// @flow
|
||||
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,5 +1,5 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
className?: string,
|
||||
};
|
||||
|
||||
function SlackLogo({ size = 34, fill = '#FFF', className }: Props) {
|
||||
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
@@ -0,0 +1,46 @@
|
||||
// @flow
|
||||
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,16 +1,32 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import LoadingIndicator from 'components/LoadingIndicator';
|
||||
import { isCustomSubdomain } from 'shared/utils/domains';
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
children?: React.Node,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const Authenticated = observer(({ auth, children }: Props) => {
|
||||
const Authenticated = ({ children }: Props) => {
|
||||
const { auth } = useStores();
|
||||
const { i18n } = useTranslation();
|
||||
const language = auth.user && auth.user.language;
|
||||
|
||||
// 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("_", "-"));
|
||||
}
|
||||
}, [i18n, language]);
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
const { hostname } = window.location;
|
||||
@@ -19,10 +35,15 @@ const Authenticated = observer(({ auth, children }: Props) => {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
// If we're authenticated but viewing a subdomain that doesn't match the
|
||||
// currently authenticated team then kick the user to the teams subdomain.
|
||||
if (
|
||||
process.env.SUBDOMAINS_ENABLED &&
|
||||
// If we're authenticated but viewing a domain that doesn't match the
|
||||
// current team then kick the user to the teams correct domain.
|
||||
if (team.domain) {
|
||||
if (team.domain !== hostname) {
|
||||
window.location.href = `${team.url}${window.location.pathname}`;
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
} else if (
|
||||
env.SUBDOMAINS_ENABLED &&
|
||||
team.subdomain &&
|
||||
isCustomSubdomain(hostname) &&
|
||||
!hostname.startsWith(`${team.subdomain}.`)
|
||||
@@ -35,7 +56,7 @@ const Authenticated = observer(({ auth, children }: Props) => {
|
||||
}
|
||||
|
||||
auth.logout(true);
|
||||
return null;
|
||||
});
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
export default inject('auth')(Authenticated);
|
||||
export default observer(Authenticated);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import placeholder from './placeholder.png';
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
src: string,
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
};
|
||||
user?: User,
|
||||
onClick?: () => void,
|
||||
className?: string,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Avatar extends React.Component<Props> {
|
||||
@@ -48,8 +52,8 @@ const IconWrapper = styled.div`
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
background: ${props => props.theme.primary};
|
||||
border: 2px solid ${props => props.theme.background};
|
||||
background: ${(props) => props.theme.primary};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
border-radius: 100%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -57,10 +61,10 @@ const IconWrapper = styled.div`
|
||||
|
||||
const CircleImg = styled.img`
|
||||
display: block;
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${props => props.theme.background};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||
import Avatar from 'components/Avatar';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import User from 'models/User';
|
||||
import UserProfile from 'scenes/UserProfile';
|
||||
import { EditIcon } from 'outline-icons';
|
||||
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,
|
||||
profileOnClick: boolean,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -31,24 +32,26 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
lastViewedAt,
|
||||
isPresent,
|
||||
isEditing,
|
||||
isCurrentUser,
|
||||
} = this.props;
|
||||
const { user, isPresent, isEditing, isCurrentUser, t } = this.props;
|
||||
|
||||
const action = isPresent
|
||||
? isEditing
|
||||
? t("currently editing")
|
||||
: t("currently viewing")
|
||||
: t("previously edited");
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{user.name}</strong> {isCurrentUser && '(You)'}
|
||||
<br />
|
||||
{isPresent
|
||||
? isEditing ? 'currently editing' : 'currently viewing'
|
||||
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
|
||||
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
||||
{action && (
|
||||
<>
|
||||
<br />
|
||||
{action}
|
||||
</>
|
||||
)}
|
||||
</Centered>
|
||||
}
|
||||
placement="bottom"
|
||||
@@ -56,7 +59,11 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
<AvatarWrapper isPresent={isPresent}>
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
onClick={this.handleOpenProfile}
|
||||
onClick={
|
||||
this.props.profileOnClick === false
|
||||
? undefined
|
||||
: this.handleOpenProfile
|
||||
}
|
||||
size={32}
|
||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||
/>
|
||||
@@ -67,7 +74,7 @@ class AvatarWithPresence extends React.Component<Props> {
|
||||
isOpen={this.isOpen}
|
||||
onRequestClose={this.handleCloseProfile}
|
||||
/>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,8 +84,8 @@ const Centered = styled.div`
|
||||
`;
|
||||
|
||||
const AvatarWrapper = styled.div`
|
||||
opacity: ${props => (props.isPresent ? 1 : 0.5)};
|
||||
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default AvatarWithPresence;
|
||||
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import Avatar from './Avatar';
|
||||
import AvatarWithPresence from './AvatarWithPresence';
|
||||
import Avatar from "./Avatar";
|
||||
import AvatarWithPresence from "./AvatarWithPresence";
|
||||
|
||||
export { AvatarWithPresence };
|
||||
export default Avatar;
|
||||
|
||||
+11
-8
@@ -1,16 +1,19 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const Badge = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
background-color: ${({ admin, theme }) =>
|
||||
admin ? theme.primary : theme.textTertiary};
|
||||
color: ${({ admin, theme }) => (admin ? theme.white : theme.background)};
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
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" : theme.textTertiary};
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import OutlineLogo from "./OutlineLogo";
|
||||
import env from "env";
|
||||
|
||||
type Props = {
|
||||
href?: string,
|
||||
};
|
||||
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<OutlineLogo size={16} />
|
||||
Outline
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const Link = styled.a`
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
border-top-right-radius: 2px;
|
||||
color: ${(props) => props.theme.text};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
@@ -0,0 +1,87 @@
|
||||
// @flow
|
||||
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";
|
||||
|
||||
type MenuItem = {|
|
||||
icon?: React.Node,
|
||||
title: React.Node,
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
items: MenuItem[],
|
||||
max?: number,
|
||||
children?: React.Node,
|
||||
highlightFirstItem?: boolean,
|
||||
|};
|
||||
|
||||
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
|
||||
const totalItems = items.length;
|
||||
let topLevelItems: MenuItem[] = [...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, {
|
||||
title: <BreadcrumbMenu items={overflowItems} />,
|
||||
});
|
||||
}
|
||||
|
||||
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)`
|
||||
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,23 +1,27 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { bounceIn } from 'shared/styles/animations';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { bounceIn } from "shared/styles/animations";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
count: number,
|
||||
};
|
||||
|};
|
||||
|
||||
const Bubble = ({ count }: Props) => {
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Count>{count}</Count>;
|
||||
};
|
||||
|
||||
const Count = styled.div`
|
||||
animation: ${bounceIn} 600ms;
|
||||
transform-origin: center center;
|
||||
color: ${props => props.theme.white};
|
||||
background: ${props => props.theme.slateDark};
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
display: inline-block;
|
||||
font-feature-settings: 'tnum';
|
||||
font-feature-settings: "tnum";
|
||||
font-weight: 600;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
+87
-80
@@ -1,16 +1,17 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { darken, lighten } from 'polished';
|
||||
import { ExpandedIcon } from 'outline-icons';
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const RealButton = styled.button`
|
||||
display: inline-block;
|
||||
display: ${(props) => (props.fullwidth ? "block" : "inline-block")};
|
||||
width: ${(props) => (props.fullwidth ? "100%" : "auto")};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${props => props.theme.buttonBackground};
|
||||
color: ${props => props.theme.buttonText};
|
||||
background: ${(props) => props.theme.buttonBackground};
|
||||
color: ${(props) => props.theme.buttonText};
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
@@ -18,13 +19,16 @@ const RealButton = styled.button`
|
||||
height: 32px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
fill: ${props => props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
${(props) =>
|
||||
!props.borderOnHover &&
|
||||
`
|
||||
svg {
|
||||
fill: ${props.iconColor || props.theme.buttonText};
|
||||
}
|
||||
`}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
@@ -32,68 +36,54 @@ const RealButton = styled.button`
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${props => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${props => lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 3px;
|
||||
outline: none;
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: ${props => props.theme.white50};
|
||||
color: ${(props) => props.theme.white50};
|
||||
}
|
||||
|
||||
${props =>
|
||||
props.neutral &&
|
||||
${(props) =>
|
||||
props.$neutral &&
|
||||
`
|
||||
background: ${props.theme.buttonNeutralBackground};
|
||||
color: ${props.theme.buttonNeutralText};
|
||||
box-shadow: ${
|
||||
props.borderOnHover ? 'none' : 'rgba(0, 0, 0, 0.07) 0px 1px 2px'
|
||||
};
|
||||
border: 1px solid ${
|
||||
props.borderOnHover ? 'transparent' : props.theme.buttonNeutralBorder
|
||||
props.borderOnHover
|
||||
? "none"
|
||||
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
|
||||
};
|
||||
|
||||
svg {
|
||||
${
|
||||
props.borderOnHover
|
||||
? ""
|
||||
: `svg {
|
||||
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||
}`
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
border: 1px solid ${lighten(0.4, props.theme.buttonBackground)};
|
||||
box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 2px;
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
|
||||
props.theme.buttonNeutralBorder
|
||||
} 0 0 0 1px inset;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
}
|
||||
`} ${props =>
|
||||
props.danger &&
|
||||
`
|
||||
`} ${(props) =>
|
||||
props.danger &&
|
||||
`
|
||||
background: ${props.theme.danger};
|
||||
color: ${props.theme.white};
|
||||
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px
|
||||
0px 3px;
|
||||
}
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -102,23 +92,24 @@ const Label = styled.span`
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
${props => props.hasIcon && 'padding-left: 4px;'};
|
||||
${(props) => props.hasIcon && "padding-left: 4px;"};
|
||||
`;
|
||||
|
||||
export const Inner = styled.span`
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
padding-right: ${props => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${props => (props.hasIcon ? 24 : 32)}px;
|
||||
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
|
||||
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
|
||||
${props => props.hasIcon && props.hasText && 'padding-left: 4px;'};
|
||||
${props => props.hasIcon && !props.hasText && 'padding: 0 4px;'};
|
||||
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
export type Props = {|
|
||||
type?: "button" | "submit",
|
||||
value?: string,
|
||||
icon?: React.Node,
|
||||
iconColor?: string,
|
||||
@@ -126,33 +117,49 @@ export type Props = {
|
||||
children?: React.Node,
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
neutral?: boolean,
|
||||
danger?: boolean,
|
||||
primary?: boolean,
|
||||
disabled?: boolean,
|
||||
fullwidth?: boolean,
|
||||
autoFocus?: boolean,
|
||||
style?: Object,
|
||||
as?: React.ComponentType<any>,
|
||||
to?: string,
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
borderOnHover?: boolean,
|
||||
};
|
||||
|
||||
function Button({
|
||||
type = 'text',
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
innerRef,
|
||||
...rest
|
||||
}: Props) {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
"data-on"?: string,
|
||||
"data-event-category"?: string,
|
||||
"data-event-action"?: string,
|
||||
|};
|
||||
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
const Button = React.forwardRef<Props, HTMLButtonElement>(
|
||||
(
|
||||
{
|
||||
type = "text",
|
||||
icon,
|
||||
children,
|
||||
value,
|
||||
disclosure,
|
||||
neutral,
|
||||
...rest
|
||||
}: Props,
|
||||
innerRef
|
||||
) => {
|
||||
const hasText = children !== undefined || value !== undefined;
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
// $FlowFixMe - need to upgrade to get forwardRef
|
||||
export default React.forwardRef((props, ref) => (
|
||||
<Button {...props} innerRef={ref} />
|
||||
));
|
||||
return (
|
||||
<RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "./Button";
|
||||
|
||||
const ButtonLarge = styled(Button)`
|
||||
height: 40px;
|
||||
|
||||
${Inner} {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ButtonLarge;
|
||||
@@ -0,0 +1,23 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick: (ev: SyntheticEvent<>) => void,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function ButtonLink(props: Props) {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: ${(props) => props.theme.link};
|
||||
line-height: inherit;
|
||||
background: none;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
@@ -1,24 +1,30 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import breakpoint from 'styled-components-breakpoint';
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
};
|
||||
withStickyHeader?: boolean,
|
||||
|};
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
padding: 60px 20px;
|
||||
max-width: 100vw;
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint('tablet')`
|
||||
padding: 60px;
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (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) => {
|
||||
|
||||
+17
-12
@@ -1,27 +1,32 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import HelpText from 'components/HelpText';
|
||||
import VisuallyHidden from 'components/VisuallyHidden';
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import HelpText from "components/HelpText";
|
||||
|
||||
export type Props = {
|
||||
export type Props = {|
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
label?: React.Node,
|
||||
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}` : '')};
|
||||
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' : '')};
|
||||
${(props) => (props.small ? "font-size: 14px" : "")};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
@@ -42,7 +47,7 @@ export default function Checkbox({
|
||||
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<Wrapper small={small}>
|
||||
<Label>
|
||||
<input type="checkbox" {...rest} />
|
||||
@@ -55,6 +60,6 @@ export default function Checkbox({
|
||||
</Label>
|
||||
{note && <HelpText small>{note}</HelpText>}
|
||||
</Wrapper>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const ClickablePadding = styled.div`
|
||||
min-height: 10em;
|
||||
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
|
||||
cursor: ${({ onClick }) => (onClick ? "text" : "default")};
|
||||
${({ grow }) => grow && `flex-grow: 100;`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,76 +1,109 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { sortBy, keyBy } from 'lodash';
|
||||
import { MAX_AVATAR_DISPLAY } from 'shared/constants';
|
||||
import { sortBy, filter, uniq } from "lodash";
|
||||
import { observer } 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 breakpoint from "styled-components-breakpoint";
|
||||
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 useStores from "hooks/useStores";
|
||||
|
||||
import { AvatarWithPresence } from 'components/Avatar';
|
||||
import Facepile from 'components/Facepile';
|
||||
import Document from 'models/Document';
|
||||
import ViewsStore from 'stores/ViewsStore';
|
||||
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
|
||||
|
||||
type Props = {
|
||||
views: ViewsStore,
|
||||
presence: DocumentPresenceStore,
|
||||
type Props = {|
|
||||
document: Document,
|
||||
currentUserId: string,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Collaborators extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||
}
|
||||
function Collaborators(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { users, presence } = useStores();
|
||||
const { document, currentUserId } = props;
|
||||
|
||||
render() {
|
||||
const { document, presence, views, currentUserId } = this.props;
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
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);
|
||||
|
||||
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 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]
|
||||
);
|
||||
|
||||
// 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);
|
||||
// load any users we don't know about
|
||||
React.useEffect(() => {
|
||||
if (users.isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
uniq([...document.collaboratorIds, ...presentIds]).forEach((userId) => {
|
||||
if (!users.get(userId)) {
|
||||
return users.fetch(userId);
|
||||
}
|
||||
);
|
||||
});
|
||||
}, [document, users, presentIds, document.collaboratorIds]);
|
||||
|
||||
const viewersKeyedByUserId = keyBy(mostRecentViewers, v => v.user.id);
|
||||
const overflow = documentViews.length - mostRecentViewers.length;
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
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 (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<NudeButton width={collaborators.length * 32} height={32} {...props}>
|
||||
<FacepileHiddenOnMobile
|
||||
users={collaborators}
|
||||
renderAvatar={(user) => {
|
||||
const isPresent = presentIds.includes(user.id);
|
||||
const isEditing = editingIds.includes(user.id);
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={user.id}
|
||||
user={user}
|
||||
lastViewedAt={lastViewedAt}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isCurrentUser={currentUserId === user.id}
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={user.id}
|
||||
user={user}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
isCurrentUser={currentUserId === user.id}
|
||||
profileOnClick={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
</NudeButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||
<DocumentViews document={document} isOpen={popover.visible} />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject('views', 'presence')(Collaborators);
|
||||
const FacepileHiddenOnMobile = styled(Facepile)`
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default observer(Collaborators);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// @flow
|
||||
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";
|
||||
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
|};
|
||||
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections, ui, policies } = useStores();
|
||||
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) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setExpanded(!isExpanded);
|
||||
},
|
||||
[isExpanded]
|
||||
);
|
||||
|
||||
const handleSave = useDebouncedCallback(async (getValue) => {
|
||||
try {
|
||||
await collection.save({
|
||||
description: getValue(),
|
||||
});
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
ui.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
|
||||
$isEditable={can.update}
|
||||
data-editing={isEditing}
|
||||
data-expanded={isExpanded}
|
||||
>
|
||||
<span onClick={can.update ? handleStartEditing : undefined}>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{collection.hasDescription || isEditing || isDirty ? (
|
||||
<React.Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<Editor
|
||||
key={key}
|
||||
defaultValue={collection.description || ""}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={!isEditing}
|
||||
autoFocus={isEditing}
|
||||
onBlur={handleStopEditing}
|
||||
maxLength={1000}
|
||||
disableEmbeds
|
||||
readOnlyWriteCheckboxes
|
||||
grow
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
can.update && <Placeholder>{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);
|
||||
@@ -0,0 +1,40 @@
|
||||
// @flow
|
||||
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,
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({ collection, 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 =
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return <CollectionIcon color={color} expanded={expanded} size={size} />;
|
||||
}
|
||||
|
||||
export default observer(ResolvedCollectionIcon);
|
||||
@@ -1,106 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { TwitterPicker } from 'react-color';
|
||||
import styled from 'styled-components';
|
||||
import Fade from 'components/Fade';
|
||||
import { LabelText } from 'components/Input';
|
||||
|
||||
const colors = [
|
||||
'#4E5C6E',
|
||||
'#19B7FF',
|
||||
'#7F6BFF',
|
||||
'#FC7419',
|
||||
'#FC2D2D',
|
||||
'#FFE100',
|
||||
'#14CF9F',
|
||||
'#00D084',
|
||||
'#EE84F0',
|
||||
'#2F362F',
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onChange: (color: string) => void,
|
||||
value?: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
class ColorPicker extends React.Component<Props> {
|
||||
@observable isOpen: boolean = false;
|
||||
node: ?HTMLElement;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.isOpen = false;
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.isOpen = true;
|
||||
};
|
||||
|
||||
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
|
||||
// $FlowFixMe
|
||||
if (ev.target && this.node && this.node.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Wrapper ref={ref => (this.node = ref)}>
|
||||
<label>
|
||||
<LabelText>Color</LabelText>
|
||||
</label>
|
||||
<Swatch
|
||||
role="button"
|
||||
onClick={this.isOpen ? this.handleClose : this.handleOpen}
|
||||
color={this.props.value}
|
||||
/>
|
||||
<Floating>
|
||||
{this.isOpen && (
|
||||
<Fade>
|
||||
<TwitterPicker
|
||||
colors={colors}
|
||||
color={this.props.value}
|
||||
onChange={color => this.props.onChange(color.hex)}
|
||||
triangle="top-right"
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
</Floating>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Wrapper = styled('div')`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
`;
|
||||
const Floating = styled('div')`
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const Swatch = styled('div')`
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
border: 1px solid ${({ active, color }) => (active ? 'white' : 'transparent')};
|
||||
border-radius: 4px;
|
||||
background: ${({ color }) => color};
|
||||
`;
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
letter-spacing: 0.04em;
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,136 @@
|
||||
// @flow
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
type Props = {|
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
to?: string,
|
||||
href?: string,
|
||||
target?: "_blank",
|
||||
as?: string | React.ComponentType<*>,
|
||||
hide?: () => void,
|
||||
|};
|
||||
|
||||
const MenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
as,
|
||||
hide,
|
||||
...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 />}
|
||||
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</MenuAnchor>
|
||||
)}
|
||||
</BaseMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.svg`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuAnchor = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 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: 16px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
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.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
|
||||
font-size: 15px;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default MenuItem;
|
||||
@@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
export default function OverflowMenuButton({
|
||||
iconColor,
|
||||
className,
|
||||
...rest
|
||||
}: any) {
|
||||
return (
|
||||
<MenuButton {...rest}>
|
||||
{(props) => (
|
||||
<NudeButton className={className} {...props}>
|
||||
<MoreIcon color={iconColor} />
|
||||
</NudeButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: {}) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
</MenuSeparator>
|
||||
);
|
||||
}
|
||||
|
||||
const HorizontalRule = styled.hr`
|
||||
margin: 0.5em 12px;
|
||||
`;
|
||||
@@ -0,0 +1,174 @@
|
||||
// @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);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
`;
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
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;
|
||||
}
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
return filterTemplateItems(items).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,121 @@
|
||||
// @flow
|
||||
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 {
|
||||
fadeIn,
|
||||
fadeAndScaleIn,
|
||||
fadeAndSlideIn,
|
||||
} 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 hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background dir="auto">
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
)}
|
||||
</Menu>
|
||||
{(rest.visible || rest.animating) && (
|
||||
<Portal>
|
||||
<Backdrop />
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
top: auto !important;
|
||||
right: 8px !important;
|
||||
bottom: 16px !important;
|
||||
left: 8px !important;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Background = styled.div`
|
||||
animation: ${fadeAndSlideIn} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
max-width: 100%;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
pointer-events: all;
|
||||
font-weight: normal;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${(props) =>
|
||||
props.left !== undefined ? "25%" : "75%"} 0;
|
||||
max-width: 276px;
|
||||
background: ${(props) => props.theme.menuBackground};
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
`};
|
||||
`;
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import copy from "copy-to-clipboard";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
text: string,
|
||||
@@ -14,12 +14,13 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
const { text, onCopy, children } = this.props;
|
||||
const elem = React.Children.only(children);
|
||||
copy(text, {
|
||||
debug: !!__DEV__,
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
|
||||
if (onCopy) onCopy();
|
||||
|
||||
if (elem && elem.props && typeof elem.props.onClick === 'function') {
|
||||
if (elem && elem.props && typeof elem.props.onClick === "function") {
|
||||
elem.props.onClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
delay?: number,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function DelayedMount({ delay = 250, children }: Props) {
|
||||
const [isShowing, setShowing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => setShowing(true), delay);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [delay]);
|
||||
|
||||
if (!isShowing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// @flow
|
||||
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,138 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
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 { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
children?: React.Node,
|
||||
onlyText: boolean,
|
||||
|};
|
||||
|
||||
function useCategory(document) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.isDeleted) {
|
||||
return {
|
||||
icon: <TrashIcon color="currentColor" />,
|
||||
title: t("Trash"),
|
||||
to: "/trash",
|
||||
};
|
||||
}
|
||||
if (document.isArchived) {
|
||||
return {
|
||||
icon: <ArchiveIcon color="currentColor" />,
|
||||
title: t("Archive"),
|
||||
to: "/archive",
|
||||
};
|
||||
}
|
||||
if (document.isDraft) {
|
||||
return {
|
||||
icon: <EditIcon color="currentColor" />,
|
||||
title: t("Drafts"),
|
||||
to: "/drafts",
|
||||
};
|
||||
}
|
||||
if (document.isTemplate) {
|
||||
return {
|
||||
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);
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
color: "currentColor",
|
||||
url: "deleted-collection",
|
||||
};
|
||||
}
|
||||
|
||||
const path = React.useMemo(
|
||||
() =>
|
||||
collection && collection.pathToDocument
|
||||
? collection.pathToDocument(document.id).slice(0, -1)
|
||||
: [],
|
||||
[collection, document.id]
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
let output = [];
|
||||
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
output.push({
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
title: collection.name,
|
||||
to: collectionUrl(collection.url),
|
||||
});
|
||||
}
|
||||
|
||||
path.forEach((p) => {
|
||||
output.push({
|
||||
title: p.title,
|
||||
to: p.url,
|
||||
});
|
||||
});
|
||||
|
||||
return output;
|
||||
}, [path, category, collection]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
<>
|
||||
{collection.name}
|
||||
{path.map((n) => (
|
||||
<React.Fragment key={n.id}>
|
||||
<SmallSlash />
|
||||
{n.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);
|
||||
@@ -1,23 +1,26 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable, action } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import type { RouterHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
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 { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import RevisionsStore from 'stores/RevisionsStore';
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import RevisionsStore from "stores/RevisionsStore";
|
||||
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||
import Revision from './components/Revision';
|
||||
import { documentHistoryUrl } from 'utils/routeHelpers';
|
||||
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: Object,
|
||||
match: Match,
|
||||
documents: DocumentsStore,
|
||||
revisions: RevisionsStore,
|
||||
history: RouterHistory,
|
||||
@@ -29,6 +32,7 @@ class DocumentHistory extends React.Component<Props> {
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
async componentDidMount() {
|
||||
await this.loadMoreResults();
|
||||
@@ -42,7 +46,7 @@ class DocumentHistory extends React.Component<Props> {
|
||||
const results = await this.props.revisions.fetchPage({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
id: this.props.match.params.documentSlug,
|
||||
documentId: this.props.match.params.documentSlug,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -86,15 +90,34 @@ class DocumentHistory extends React.Component<Props> {
|
||||
return this.props.revisions.getDocumentRevisions(document.id);
|
||||
}
|
||||
|
||||
onCloseHistory = () => {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
|
||||
this.redirectTo = documentUrl(document);
|
||||
};
|
||||
|
||||
render() {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
const showLoading = (!this.isLoaded && this.isFetching) || !document;
|
||||
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<Wrapper column>
|
||||
<Header>
|
||||
<Title>History</Title>
|
||||
<Button
|
||||
icon={<CloseIcon />}
|
||||
onClick={this.onCloseHistory}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Header>
|
||||
{showLoading ? (
|
||||
<Loading>
|
||||
<ListPlaceholder count={5} />
|
||||
@@ -133,17 +156,44 @@ const Wrapper = styled(Flex)`
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
min-width: ${props => props.theme.sidebarWidth};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: none;
|
||||
`;
|
||||
|
||||
const Sidebar = styled(Flex)`
|
||||
background: ${props => props.theme.background};
|
||||
min-width: ${props => props.theme.sidebarWidth};
|
||||
border-left: 1px solid ${props => props.theme.divider};
|
||||
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;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default inject('documents', 'revisions')(DocumentHistory);
|
||||
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,21 +1,21 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import format from 'date-fns/format';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
import { format } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Time from 'shared/components/Time';
|
||||
import Avatar from 'components/Avatar';
|
||||
import RevisionMenu from 'menus/RevisionMenu';
|
||||
import Document from 'models/Document';
|
||||
import Revision from 'models/Revision';
|
||||
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';
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
theme: Object,
|
||||
theme: Theme,
|
||||
showMenu: boolean,
|
||||
selected: boolean,
|
||||
document: Document,
|
||||
@@ -32,21 +32,19 @@ class RevisionListItem extends React.Component<Props> {
|
||||
activeStyle={{ background: theme.primary, color: theme.white }}
|
||||
>
|
||||
<Author>
|
||||
<StyledAvatar src={revision.createdBy.avatarUrl} />{' '}
|
||||
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
|
||||
{revision.createdBy.name}
|
||||
</Author>
|
||||
<Meta>
|
||||
<Time dateTime={revision.createdAt}>
|
||||
{format(revision.createdAt, 'MMMM Do, YYYY h:mm a')}
|
||||
<Time dateTime={revision.createdAt} tooltipDelay={250}>
|
||||
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
|
||||
</Time>
|
||||
</Meta>
|
||||
{showMenu && (
|
||||
<StyledRevisionMenu
|
||||
document={document}
|
||||
revision={revision}
|
||||
label={
|
||||
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
|
||||
}
|
||||
iconColor={selected ? theme.white : theme.textTertiary}
|
||||
/>
|
||||
)}
|
||||
</StyledNavLink>
|
||||
@@ -66,7 +64,7 @@ const StyledRevisionMenu = styled(RevisionMenu)`
|
||||
`;
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
import DocumentHistory from './DocumentHistory';
|
||||
import DocumentHistory from "./DocumentHistory";
|
||||
export default DocumentHistory;
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Document from 'models/Document';
|
||||
import DocumentPreview from 'components/DocumentPreview';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
documents: Document[],
|
||||
limit?: number,
|
||||
};
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
@@ -17,8 +22,8 @@ export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.map(document => (
|
||||
<DocumentPreview key={document.id} document={document} {...rest} />
|
||||
{items.map((document) => (
|
||||
<DocumentListItem key={document.id} document={document} {...rest} />
|
||||
))}
|
||||
</ArrowKeyNavigation>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
import Button from "components/Button";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import Highlight from "components/Highlight";
|
||||
import StarButton, { AnimatedStar } from "components/Star";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(props: Props, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
const currentTeam = useCurrentTeam();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const {
|
||||
document,
|
||||
showNestedDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
} = props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = policies.abilities(currentTeam.id);
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.titleWithDefault },
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
tooltip={t("Only visible to you")}
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showNestedDocuments={showNestedDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
{document.isTemplate &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
can.createDocument && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
to={newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
</>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={() => setMenuOpen(true)}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)`
|
||||
display: none;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$menuOpen &&
|
||||
css`
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
|
||||
${Actions} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
@@ -0,0 +1,162 @@
|
||||
// @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 DocumentBreadcrumb from "components/DocumentBreadcrumb";
|
||||
import Flex from "components/Flex";
|
||||
import Time from "components/Time";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const Container = styled(Flex)`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Viewed = styled.span`
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
type Props = {|
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showLastViewed?: boolean,
|
||||
showNestedDocuments?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
function DocumentMeta({
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
showNestedDocuments,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections, auth } = useStores();
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
createdAt,
|
||||
publishedAt,
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
isDraft,
|
||||
lastViewedAt,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
// Currently the situation where this is true is rendering share links.
|
||||
if (!updatedBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let content;
|
||||
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("archived")} <Time dateTime={archivedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
{t("created")} <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
{t("published")} <Time dateTime={publishedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
{t("saved")} <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
{t("updated")} <Time dateTime={updatedAt} addSuffix />
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
|
||||
|
||||
const timeSinceNow = () => {
|
||||
if (isDraft || !showLastViewed) {
|
||||
return null;
|
||||
}
|
||||
if (!lastViewedAt) {
|
||||
return (
|
||||
<Viewed>
|
||||
• <Modified highlight>{t("Never viewed")}</Modified>
|
||||
</Viewed>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Viewed>
|
||||
• {t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</Viewed>
|
||||
);
|
||||
};
|
||||
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
{t("in")}
|
||||
<strong>
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showNestedDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
· {nestedDocumentsCount}{" "}
|
||||
{t("nested document", { count: nestedDocumentsCount })}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentMeta);
|
||||
@@ -0,0 +1,85 @@
|
||||
// @flow
|
||||
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)`
|
||||
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;
|
||||
@@ -1,195 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { StarredIcon } from 'outline-icons';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Badge from 'components/Badge';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import Highlight from 'components/Highlight';
|
||||
import PublishingInfo from 'components/PublishingInfo';
|
||||
import DocumentMenu from 'menus/DocumentMenu';
|
||||
import Document from 'models/Document';
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
};
|
||||
|
||||
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
|
||||
<StarredIcon color={theme.text} {...props} />
|
||||
))`
|
||||
flex-shrink: 0;
|
||||
opacity: ${props => (props.solid ? '1 !important' : 0)};
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`);
|
||||
|
||||
const StyledDocumentMenu = styled(DocumentMenu)`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: block;
|
||||
margin: 10px -8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
${StyledDocumentMenu} {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${props => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${StyledStar}, ${StyledDocumentMenu} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
margin-left: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled(Highlight)`
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ResultContext = styled(Highlight)`
|
||||
display: block;
|
||||
color: ${props => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
margin-top: -0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
star = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.star();
|
||||
};
|
||||
|
||||
unstar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.document.unstar();
|
||||
};
|
||||
|
||||
replaceResultMarks = (tag: string) => {
|
||||
// don't use SEARCH_RESULT_REGEX here as it causes
|
||||
// an infinite loop to trigger a regex inside it's own callback
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, '$1');
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
document,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.title },
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.title || 'Untitled'} highlight={highlight} />
|
||||
{!document.isDraft &&
|
||||
!document.isArchived && (
|
||||
<Actions>
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.unstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.star} />
|
||||
)}
|
||||
</Actions>
|
||||
)}
|
||||
{document.isDraft &&
|
||||
showDraft && (
|
||||
<Tooltip
|
||||
tooltip="Only visible to you"
|
||||
delay={500}
|
||||
placement="top"
|
||||
>
|
||||
<Badge>Draft</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<StyledDocumentMenu document={document} showPin={showPin} />
|
||||
</Heading>
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={this.replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<PublishingInfo
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DocumentPreview;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import DocumentPreview from './DocumentPreview';
|
||||
export default DocumentPreview;
|
||||
@@ -0,0 +1,80 @@
|
||||
// @flow
|
||||
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();
|
||||
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
: [];
|
||||
|
||||
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 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}
|
||||
small
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentViews);
|
||||
@@ -1,113 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import invariant from 'invariant';
|
||||
import importFile from 'utils/importFile';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import LoadingIndicator from 'components/LoadingIndicator';
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
let importingLock = false;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
collectionId: string,
|
||||
documentId?: string,
|
||||
activeClassName?: string,
|
||||
rejectClassName?: string,
|
||||
documents: DocumentsStore,
|
||||
disabled: boolean,
|
||||
location: Object,
|
||||
match: Object,
|
||||
history: RouterHistory,
|
||||
staticContext: Object,
|
||||
};
|
||||
|
||||
export const GlobalStyles = createGlobalStyle`
|
||||
.activeDropZone {
|
||||
border-radius: 4px;
|
||||
background: ${props => props.theme.slateDark};
|
||||
svg { fill: ${props => props.theme.white}; }
|
||||
}
|
||||
|
||||
.activeDropZone a {
|
||||
color: ${props => props.theme.white} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@observer
|
||||
class DropToImport extends React.Component<Props> {
|
||||
@observable isImporting: boolean = false;
|
||||
|
||||
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 importFile({
|
||||
documents: this.props.documents,
|
||||
file,
|
||||
documentId,
|
||||
collectionId,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
this.props.history.push(doc.url);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isImporting = false;
|
||||
importingLock = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
documentId,
|
||||
collectionId,
|
||||
documents,
|
||||
disabled,
|
||||
location,
|
||||
match,
|
||||
history,
|
||||
staticContext,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
if (this.props.disabled) return this.props.children;
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept="text/markdown, text/plain"
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={EMPTY_OBJECT}
|
||||
disableClick
|
||||
disablePreview
|
||||
multiple
|
||||
{...rest}
|
||||
>
|
||||
{this.isImporting && <LoadingIndicator />}
|
||||
{this.props.children}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('documents')(withRouter(DropToImport));
|
||||
@@ -1,269 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import invariant from 'invariant';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { PortalWithState } from 'react-portal';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
|
||||
let previousClosePortal;
|
||||
let counter = 0;
|
||||
|
||||
type Children =
|
||||
| React.Node
|
||||
| ((options: { closePortal: () => void }) => React.Node);
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
children?: Children,
|
||||
className?: string,
|
||||
hover?: boolean,
|
||||
style?: Object,
|
||||
position?: 'left' | 'right' | 'center',
|
||||
};
|
||||
|
||||
@observer
|
||||
class DropdownMenu extends React.Component<Props> {
|
||||
id: string = `menu${counter++}`;
|
||||
closeTimeout: TimeoutID;
|
||||
|
||||
@observable top: ?number;
|
||||
@observable bottom: ?number;
|
||||
@observable right: ?number;
|
||||
@observable left: ?number;
|
||||
@observable position: 'left' | 'right' | 'center';
|
||||
@observable fixed: ?boolean;
|
||||
@observable bodyRect: ClientRect;
|
||||
@observable labelRect: ClientRect;
|
||||
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
|
||||
@observable menuRef: { current: null | HTMLElement } = React.createRef();
|
||||
|
||||
handleOpen = (
|
||||
openPortal: (SyntheticEvent<>) => void,
|
||||
closePortal: () => void
|
||||
) => {
|
||||
return (ev: SyntheticMouseEvent<HTMLElement>) => {
|
||||
ev.preventDefault();
|
||||
const currentTarget = ev.currentTarget;
|
||||
invariant(document.body, 'why you not here');
|
||||
|
||||
if (currentTarget instanceof HTMLDivElement) {
|
||||
this.bodyRect = document.body.getBoundingClientRect();
|
||||
this.labelRect = currentTarget.getBoundingClientRect();
|
||||
this.top = this.labelRect.bottom - this.bodyRect.top;
|
||||
this.bottom = undefined;
|
||||
this.position = this.props.position || 'left';
|
||||
|
||||
if (currentTarget.parentElement) {
|
||||
const triggerParentStyle = getComputedStyle(
|
||||
currentTarget.parentElement
|
||||
);
|
||||
|
||||
if (triggerParentStyle.position === 'static') {
|
||||
this.fixed = true;
|
||||
this.top = this.labelRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
|
||||
// attempt to keep only one flyout menu open at once
|
||||
if (previousClosePortal && !this.props.hover) {
|
||||
previousClosePortal();
|
||||
}
|
||||
previousClosePortal = closePortal;
|
||||
openPortal(ev);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
initPosition() {
|
||||
if (this.position === 'left') {
|
||||
this.right =
|
||||
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
|
||||
} else if (this.position === 'center') {
|
||||
this.left = this.labelRect.left + this.labelRect.width / 2;
|
||||
} else {
|
||||
this.left = this.labelRect.left;
|
||||
}
|
||||
}
|
||||
|
||||
onOpen = () => {
|
||||
if (typeof this.props.onOpen === 'function') {
|
||||
this.props.onOpen();
|
||||
}
|
||||
this.fitOnTheScreen();
|
||||
};
|
||||
|
||||
fitOnTheScreen() {
|
||||
if (!this.dropdownRef || !this.dropdownRef.current) return;
|
||||
const el = this.dropdownRef.current;
|
||||
|
||||
const sticksOutPastBottomEdge =
|
||||
el.clientHeight + this.top > window.innerHeight;
|
||||
if (sticksOutPastBottomEdge) {
|
||||
this.top = undefined;
|
||||
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
|
||||
} else {
|
||||
this.bottom = undefined;
|
||||
}
|
||||
|
||||
if (this.position === 'left' || this.position === 'right') {
|
||||
const totalWidth =
|
||||
Math.sign(this.position === 'left' ? -1 : 1) * el.offsetLeft +
|
||||
el.scrollWidth;
|
||||
const isVisible = totalWidth < window.innerWidth;
|
||||
|
||||
if (!isVisible) {
|
||||
if (this.position === 'right') {
|
||||
this.position = 'left';
|
||||
this.left = undefined;
|
||||
} else if (this.position === 'left') {
|
||||
this.position = 'right';
|
||||
this.right = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.initPosition();
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
closeAfterTimeout = (closePortal: () => void) => () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
this.closeTimeout = setTimeout(closePortal, 500);
|
||||
};
|
||||
|
||||
clearCloseTimeout = () => {
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, hover, label, children } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<PortalWithState
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.props.onClose}
|
||||
closeOnOutsideClick
|
||||
closeOnEsc
|
||||
>
|
||||
{({ closePortal, openPortal, isOpen, portal }) => (
|
||||
<React.Fragment>
|
||||
<Label
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onMouseEnter={
|
||||
hover ? this.handleOpen(openPortal, closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
hover ? undefined : this.handleOpen(openPortal, closePortal)
|
||||
}
|
||||
>
|
||||
{label || (
|
||||
<NudeButton
|
||||
id={`${this.id}button`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen ? 'true' : 'false'}
|
||||
aria-controls={this.id}
|
||||
>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
</Label>
|
||||
{portal(
|
||||
<Position
|
||||
ref={this.dropdownRef}
|
||||
position={this.position}
|
||||
fixed={this.fixed}
|
||||
top={this.top}
|
||||
bottom={this.bottom}
|
||||
left={this.left}
|
||||
right={this.right}
|
||||
>
|
||||
<Menu
|
||||
ref={this.menuRef}
|
||||
onMouseMove={hover ? this.clearCloseTimeout : undefined}
|
||||
onMouseOut={
|
||||
hover ? this.closeAfterTimeout(closePortal) : undefined
|
||||
}
|
||||
onClick={
|
||||
typeof children === 'function'
|
||||
? undefined
|
||||
: ev => {
|
||||
ev.stopPropagation();
|
||||
closePortal();
|
||||
}
|
||||
}
|
||||
style={this.props.style}
|
||||
id={this.id}
|
||||
aria-labelledby={`${this.id}button`}
|
||||
role="menu"
|
||||
>
|
||||
{typeof children === 'function'
|
||||
? children({ closePortal })
|
||||
: children}
|
||||
</Menu>
|
||||
</Position>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</PortalWithState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Label = styled(Flex).attrs({
|
||||
justify: 'center',
|
||||
align: 'center',
|
||||
})`
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: ${({ fixed }) => (fixed ? 'fixed' : 'absolute')};
|
||||
display: flex;
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : '')};
|
||||
${({ right }) => (right !== undefined ? `right: ${right}px` : '')};
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : '')};
|
||||
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : '')};
|
||||
max-height: 75%;
|
||||
z-index: 1000;
|
||||
transform: ${props =>
|
||||
props.position === 'center' ? 'translateX(-50%)' : 'initial'};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const Menu = styled.div`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: ${props => (props.left !== undefined ? '25%' : '75%')} 0;
|
||||
background: ${props => props.theme.menuBackground};
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 0;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
box-shadow: ${props => props.theme.menuShadow};
|
||||
pointer-events: all;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default DropdownMenu;
|
||||
@@ -1,81 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { CheckmarkIcon } from 'outline-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
};
|
||||
|
||||
const DropdownMenuItem = ({
|
||||
onClick,
|
||||
children,
|
||||
selected,
|
||||
disabled,
|
||||
...rest
|
||||
}: Props) => {
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
role="menuitem"
|
||||
tabIndex="-1"
|
||||
{...rest}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<React.Fragment>
|
||||
<CheckmarkIcon
|
||||
color={selected === false ? 'transparent' : undefined}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{children}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuItem = styled.a`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
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 {
|
||||
opacity: ${props => (props.disabled ? '.5' : 1)};
|
||||
}
|
||||
|
||||
${props =>
|
||||
props.disabled
|
||||
? 'pointer-events: none;'
|
||||
: `
|
||||
|
||||
&:hover {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
export default DropdownMenuItem;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
export { default as DropdownMenu } from './DropdownMenu';
|
||||
export { default as DropdownMenuItem } from './DropdownMenuItem';
|
||||
@@ -0,0 +1,264 @@
|
||||
// @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 { light } from "shared/styles/theme";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import { type Theme } from "types";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() =>
|
||||
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
|
||||
);
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
export type Props = {|
|
||||
id?: string,
|
||||
value?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
shareId?: ?string,
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
maxLength?: number,
|
||||
scrollTo?: string,
|
||||
theme?: Theme,
|
||||
handleDOMEvents?: Object,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
onFocus?: (event: SyntheticEvent<>) => any,
|
||||
onPublish?: (event: SyntheticEvent<>) => any,
|
||||
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
onCancel?: () => any,
|
||||
onDoubleClick?: () => 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, shareId, history } = props;
|
||||
const { t } = useTranslation();
|
||||
const isPrinting = useMediaQuery("print");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (shareId) {
|
||||
navigateTo = `/share/${shareId}${navigateTo}`;
|
||||
}
|
||||
|
||||
history.push(navigateTo);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[history, shareId]
|
||||
);
|
||||
|
||||
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"),
|
||||
deleteImage: t("Delete image"),
|
||||
downloadImage: t("Download image"),
|
||||
alignImageLeft: t("Float left"),
|
||||
alignImageRight: t("Float right"),
|
||||
alignImageDefault: t("Center large"),
|
||||
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"),
|
||||
pageBreak: t("Page break"),
|
||||
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}
|
||||
theme={isPrinting ? light : props.theme}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
flex-grow: ${(props) => (props.grow ? 1 : 0)};
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
& * {
|
||||
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} />
|
||||
));
|
||||
@@ -1,116 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { lighten } from 'polished';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import RichMarkdownEditor from 'rich-markdown-editor';
|
||||
import { uploadFile } from 'utils/uploadFile';
|
||||
import isInternalUrl from 'utils/isInternalUrl';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import embeds from '../../embeds';
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
type Props = {
|
||||
id: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
history: RouterHistory,
|
||||
forwardedRef: React.Ref<RichMarkdownEditor>,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Editor extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
onUploadImage = async (file: File) => {
|
||||
const result = await uploadFile(file, { documentId: this.props.id });
|
||||
return result.url;
|
||||
};
|
||||
|
||||
onClickLink = (href: string) => {
|
||||
// on page hash
|
||||
if (href[0] === '#') {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href)) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
// probably absolute
|
||||
if (href[0] !== '/') {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
} catch (err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
}
|
||||
|
||||
this.props.history.push(navigateTo);
|
||||
} else {
|
||||
window.open(href, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
onShowToast = (message: string) => {
|
||||
this.props.ui.showToast(message);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
||||
tooltip={EditorTooltip}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledEditor = styled(RichMarkdownEditor)`
|
||||
flex-grow: ${props => (props.grow ? 1 : 0)};
|
||||
justify-content: start;
|
||||
|
||||
> div {
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
}
|
||||
|
||||
p {
|
||||
a {
|
||||
color: ${props => props.theme.link};
|
||||
border-bottom: 1px solid ${props => lighten(0.5, props.theme.link)};
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-bottom: 1px solid ${props => props.theme.link};
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EditorTooltip = ({ children, ...props }) => (
|
||||
<Tooltip offset="0, 16" delay={150} {...props}>
|
||||
<span>{children}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
|
||||
|
||||
// $FlowIssue - https://github.com/facebook/flow/issues/6103
|
||||
export default React.forwardRef((props, ref) => (
|
||||
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
|
||||
));
|
||||
@@ -1,50 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { fadeIn } from 'shared/styles/animations';
|
||||
import embeds from '../../embeds';
|
||||
|
||||
export default class Embed extends React.Component<*> {
|
||||
get url(): string {
|
||||
return this.props.attrs.href;
|
||||
}
|
||||
|
||||
getMatchResults(): ?{ component: *, matches: string[] } {
|
||||
const keys = Object.keys(embeds);
|
||||
|
||||
for (const key of keys) {
|
||||
const component = embeds[key];
|
||||
|
||||
for (const host of component.ENABLED) {
|
||||
const matches = this.url.match(host);
|
||||
if (matches) return { component, matches };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const result = this.getMatchResults();
|
||||
if (!result) return null;
|
||||
|
||||
const { isSelected, children } = this.props;
|
||||
const { component, matches } = result;
|
||||
const EmbedComponent = component;
|
||||
|
||||
return (
|
||||
<Container contentEditable={false} isSelected={isSelected}>
|
||||
<EmbedComponent matches={matches} url={this.url} />
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
text-align: center;
|
||||
animation: ${fadeIn} 500ms ease-in-out;
|
||||
line-height: 0;
|
||||
|
||||
border-radius: 3px;
|
||||
box-shadow: ${props =>
|
||||
props.isSelected ? `0 0 0 2px ${props.theme.selected}` : 'none'};
|
||||
`;
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Editor from './Editor';
|
||||
export default Editor;
|
||||
+3
-15
@@ -1,20 +1,8 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
const Empty = (props: Props) => {
|
||||
const { children, ...rest } = props;
|
||||
return <Container {...rest}>{children}</Container>;
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
color: ${props => props.theme.slate};
|
||||
text-align: center;
|
||||
const Empty = styled.p`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
export default Empty;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Button from 'components/Button';
|
||||
import CenteredContent from 'components/CenteredContent';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import { githubIssuesUrl } from '../../shared/utils/routeHelpers';
|
||||
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
|
||||
@@ -22,13 +25,25 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
this.error = error;
|
||||
console.error(error);
|
||||
|
||||
if (window.Sentry) {
|
||||
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();
|
||||
window.location.reload(true);
|
||||
};
|
||||
|
||||
handleShowDetails = () => {
|
||||
@@ -41,20 +56,39 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
if (this.error) {
|
||||
const isReported = !!window.Sentry;
|
||||
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.
|
||||
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>{this.error.toString()}</Pre>}
|
||||
{this.showDetails && <Pre>{error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>Reload</Button>{' '}
|
||||
<Button onClick={this.handleReload}>Reload</Button>{" "}
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
Report a Bug…
|
||||
@@ -73,7 +107,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${props => props.theme.smoke};
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
className?: string,
|
||||
};
|
||||
|
||||
export default function EventBoundary({ children, className }: Props) {
|
||||
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span onClick={handleClick} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+36
-40
@@ -1,45 +1,41 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Avatar from 'components/Avatar';
|
||||
import User from 'models/User';
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import Avatar from "components/Avatar";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
users: User[],
|
||||
size?: number,
|
||||
overflow: number,
|
||||
renderAvatar: (user: User) => React.Node,
|
||||
};
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
renderAvatar?: (user: User) => React.Node,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Facepile extends React.Component<Props> {
|
||||
render() {
|
||||
const {
|
||||
users,
|
||||
overflow,
|
||||
size = 32,
|
||||
renderAvatar = renderDefaultAvatar,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<More size={size}>
|
||||
<span>+{overflow}</span>
|
||||
</More>
|
||||
)}
|
||||
{users.map(user => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
function Facepile({
|
||||
users,
|
||||
overflow,
|
||||
size = 32,
|
||||
renderAvatar = DefaultAvatar,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<Avatars {...rest}>
|
||||
{overflow > 0 && (
|
||||
<More size={size}>
|
||||
<span>+{overflow}</span>
|
||||
</More>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||
))}
|
||||
</Avatars>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDefaultAvatar(user: User) {
|
||||
function DefaultAvatar(user: User) {
|
||||
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
||||
}
|
||||
|
||||
@@ -56,12 +52,12 @@ const More = styled.div`
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
min-width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
border-radius: 100%;
|
||||
background: ${props => props.theme.slate};
|
||||
color: ${props => props.theme.text};
|
||||
border: 2px solid ${props => props.theme.background};
|
||||
background: ${(props) => props.theme.slate};
|
||||
color: ${(props) => props.theme.text};
|
||||
border: 2px solid ${(props) => props.theme.background};
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -73,4 +69,4 @@ const Avatars = styled(Flex)`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default inject('views', 'presence')(withTheme(Facepile));
|
||||
export default observer(Facepile);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import { fadeIn } from 'shared/styles/animations';
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "shared/styles/animations";
|
||||
|
||||
const Fade = styled.span`
|
||||
animation: ${fadeIn} ${props => props.timing || '250ms'} ease-in-out;
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
|
||||
export default Fade;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @flow
|
||||
import { find } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "components/Button";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import HelpText from "components/HelpText";
|
||||
|
||||
type TFilterOption = {|
|
||||
key: string,
|
||||
label: string,
|
||||
note?: string,
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
options: TFilterOption[],
|
||||
activeKey: ?string,
|
||||
defaultLabel?: string,
|
||||
selectedPrefix?: string,
|
||||
className?: string,
|
||||
onSelect: (key: ?string) => void,
|
||||
|};
|
||||
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
activeKey = "",
|
||||
defaultLabel,
|
||||
selectedPrefix = "",
|
||||
className,
|
||||
onSelect,
|
||||
}: Props) => {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const selected = find(options, { key: activeKey }) || options[0];
|
||||
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
{...props}
|
||||
className={className}
|
||||
neutral
|
||||
disclosure
|
||||
small
|
||||
>
|
||||
{activeKey ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} {...menu}>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={option.key === activeKey}
|
||||
{...menu}
|
||||
>
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const LabelWithNote = styled.div`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const Note = styled(HelpText)`
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
border-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
${Inner} {
|
||||
line-height: 28px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
export default FilterOptions;
|
||||
@@ -0,0 +1,53 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type JustifyValues =
|
||||
| "center"
|
||||
| "space-around"
|
||||
| "space-between"
|
||||
| "flex-start"
|
||||
| "flex-end";
|
||||
|
||||
type AlignValues =
|
||||
| "stretch"
|
||||
| "center"
|
||||
| "baseline"
|
||||
| "flex-start"
|
||||
| "flex-end";
|
||||
|
||||
type Props = {|
|
||||
column?: ?boolean,
|
||||
shrink?: ?boolean,
|
||||
align?: AlignValues,
|
||||
justify?: JustifyValues,
|
||||
auto?: ?boolean,
|
||||
className?: string,
|
||||
children?: React.Node,
|
||||
role?: string,
|
||||
gap?: number,
|
||||
|};
|
||||
|
||||
const Flex = React.forwardRef<Props, HTMLDivElement>((props: Props, ref) => {
|
||||
const { children, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<Container ref={ref} {...restProps}>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
|
||||
flex-direction: ${({ column }) => (column ? "column" : "row")};
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
gap: ${({ gap }) => `${gap}px` || "initial"};
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
export default Flex;
|
||||
@@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Empty from "components/Empty";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
export default function FullscreenLoading() {
|
||||
return (
|
||||
<Fade timing={500}>
|
||||
<Centered>
|
||||
<Empty>Loading…</Empty>
|
||||
</Centered>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
size?: number,
|
||||
@@ -7,7 +7,7 @@ type Props = {
|
||||
className?: string,
|
||||
};
|
||||
|
||||
function GithubLogo({ size = 34, fill = '#FFF', className }: Props) {
|
||||
function GithubLogo({ size = 34, fill = "#FFF", className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
@@ -1,23 +1,25 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { MAX_AVATAR_DISPLAY } from 'shared/constants';
|
||||
import Modal from 'components/Modal';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Facepile from 'components/Facepile';
|
||||
import GroupMembers from 'scenes/GroupMembers';
|
||||
import ListItem from 'components/List/Item';
|
||||
import Group from 'models/Group';
|
||||
import CollectionGroupMembership from 'models/CollectionGroupMembership';
|
||||
import GroupMembershipsStore from 'stores/GroupMembershipsStore';
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "shared/constants";
|
||||
import GroupMembershipsStore from "stores/GroupMembershipsStore";
|
||||
import CollectionGroupMembership from "models/CollectionGroupMembership";
|
||||
import Group from "models/Group";
|
||||
import GroupMembers from "scenes/GroupMembers";
|
||||
import Facepile from "components/Facepile";
|
||||
import Flex from "components/Flex";
|
||||
import ListItem from "components/List/Item";
|
||||
import Modal from "components/Modal";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
groupMemberships: GroupMembershipsStore,
|
||||
membership?: CollectionGroupMembership,
|
||||
showFacepile: boolean,
|
||||
showFacepile?: boolean,
|
||||
showAvatar?: boolean,
|
||||
renderActions: ({ openMembersModal: () => void }) => React.Node,
|
||||
};
|
||||
|
||||
@@ -41,20 +43,25 @@ class GroupListItem extends React.Component<Props> {
|
||||
const membershipsInGroup = groupMemberships.inGroup(group.id);
|
||||
const users = membershipsInGroup
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
.map(gm => gm.user);
|
||||
.map((gm) => gm.user);
|
||||
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={
|
||||
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
|
||||
}
|
||||
subtitle={
|
||||
<React.Fragment>
|
||||
{memberCount} member{memberCount === 1 ? '' : 's'}
|
||||
</React.Fragment>
|
||||
<>
|
||||
{memberCount} member{memberCount === 1 ? "" : "s"}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
@@ -79,11 +86,20 @@ class GroupListItem extends React.Component<Props> {
|
||||
>
|
||||
<GroupMembers group={group} onSubmit={this.handleMembersModalClose} />
|
||||
</Modal>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
@@ -91,4 +107,4 @@ const Title = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject('groupMemberships')(GroupListItem);
|
||||
export default inject("groupMemberships")(GroupListItem);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import styled from "styled-components";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
isOpen: boolean,
|
||||
title?: string,
|
||||
onRequestClose: () => void,
|
||||
|};
|
||||
|
||||
const Guide = ({
|
||||
children,
|
||||
isOpen,
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({ animated: 250 });
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasOpen && isOpen) {
|
||||
dialog.show();
|
||||
}
|
||||
if (wasOpen && !isOpen) {
|
||||
dialog.hide();
|
||||
}
|
||||
}, [dialog, wasOpen, isOpen]);
|
||||
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(props) => (
|
||||
<Backdrop {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
<Scene {...props} {...rest}>
|
||||
<Content>
|
||||
{title && <Header>{title}</Header>}
|
||||
{children}
|
||||
</Content>
|
||||
</Scene>
|
||||
)}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
</DialogBackdrop>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = styled.h1`
|
||||
font-size: 18px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) => props.theme.backdrop} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Scene = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 12px;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
transition: transform 250ms ease, opacity 250ms ease;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
transform: translateX(0px);
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
export default observer(Guide);
|
||||
@@ -0,0 +1,124 @@
|
||||
// @flow
|
||||
import { throttle } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {|
|
||||
breadcrumb?: React.Node,
|
||||
title: React.Node,
|
||||
actions?: React.Node,
|
||||
|};
|
||||
|
||||
function Header({ breadcrumb, title, actions }: Props) {
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
|
||||
const handleScroll = React.useCallback(
|
||||
throttle(() => setScrolled(window.scrollY > 75), 50),
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
const handleClickTitle = React.useCallback(() => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
|
||||
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
|
||||
{isScrolled ? (
|
||||
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{actions && (
|
||||
<Actions align="center" justify="flex-end">
|
||||
{actions}
|
||||
</Actions>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Breadcrumbs = styled("div")`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
|
||||
/* Don't show breadcrumbs on mobile */
|
||||
display: none;
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Actions = styled(Flex)`
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: auto;
|
||||
padding-left: 8px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: ${(props) => props.theme.depths.header};
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backdrop-filter: blur(20px);
|
||||
min-height: 56px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
justify-content: flex-start;
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
|
||||
justify-content: "center";
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled("div")`
|
||||
display: none;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding-left: 0;
|
||||
display: block;
|
||||
`};
|
||||
|
||||
svg {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
overflow: hidden;
|
||||
flex-grow: 0 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Header);
|
||||
@@ -1,13 +1,17 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const Heading = styled.h1`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
svg {
|
||||
margin-top: 4px;
|
||||
margin-left: -6px;
|
||||
margin-right: 2px;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
import styled from "styled-components";
|
||||
|
||||
const HelpText = styled.p`
|
||||
margin-top: 0;
|
||||
color: ${props => props.theme.textSecondary};
|
||||
font-size: ${props => (props.small ? '13px' : 'inherit')};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
font-size: ${(props) => (props.small ? "13px" : "inherit")};
|
||||
`;
|
||||
|
||||
export default HelpText;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import replace from 'string-replace-to-array';
|
||||
import styled from 'styled-components';
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
highlight: ?string | RegExp,
|
||||
@@ -18,19 +18,22 @@ function Highlight({
|
||||
...rest
|
||||
}: Props) {
|
||||
let regex;
|
||||
let index = 0;
|
||||
if (highlight instanceof RegExp) {
|
||||
regex = highlight;
|
||||
} else {
|
||||
regex = new RegExp(
|
||||
(highlight || '').replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'),
|
||||
caseSensitive ? 'g' : 'gi'
|
||||
(highlight || "").replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"),
|
||||
caseSensitive ? "g" : "gi"
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span {...rest}>
|
||||
{highlight
|
||||
? replace(text, regex, (tag, index) => (
|
||||
<Mark key={index}>{processResult ? processResult(tag) : tag}</Mark>
|
||||
? replace(text, regex, (tag) => (
|
||||
<Mark key={index++}>
|
||||
{processResult ? processResult(tag) : tag}
|
||||
</Mark>
|
||||
))
|
||||
: text}
|
||||
</span>
|
||||
@@ -38,7 +41,7 @@ function Highlight({
|
||||
}
|
||||
|
||||
const Mark = styled.mark`
|
||||
background: ${props => props.theme.yellow};
|
||||
background: ${(props) => props.theme.searchHighlight};
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndSlideIn } from "shared/styles/animations";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
node: HTMLAnchorElement,
|
||||
event: MouseEvent,
|
||||
documents: DocumentsStore,
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
|
||||
const slug = parseDocumentSlug(node.href);
|
||||
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef();
|
||||
const timerOpen = React.useRef();
|
||||
const cardRef = React.useRef<?HTMLDivElement>();
|
||||
|
||||
const startCloseTimer = () => {
|
||||
stopOpenTimer();
|
||||
timerClose.current = setTimeout(() => {
|
||||
if (isVisible) setVisible(false);
|
||||
onClose();
|
||||
}, DELAY_CLOSE);
|
||||
};
|
||||
|
||||
const stopCloseTimer = () => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
|
||||
const startOpenTimer = () => {
|
||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
||||
};
|
||||
|
||||
const stopOpenTimer = () => {
|
||||
if (timerOpen.current) {
|
||||
clearTimeout(timerOpen.current);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slug) {
|
||||
documents.prefetchDocument(slug, {
|
||||
prefetch: true,
|
||||
});
|
||||
}
|
||||
|
||||
startOpenTimer();
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
node.addEventListener("mouseout", startCloseTimer);
|
||||
node.addEventListener("mouseover", stopCloseTimer);
|
||||
node.addEventListener("mouseover", startOpenTimer);
|
||||
|
||||
return () => {
|
||||
node.removeEventListener("mouseout", startCloseTimer);
|
||||
node.removeEventListener("mouseover", stopCloseTimer);
|
||||
node.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
|
||||
}
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
}, [node]);
|
||||
|
||||
const anchorBounds = node.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current
|
||||
? cardRef.current.getBoundingClientRect()
|
||||
: undefined;
|
||||
const left = cardBounds
|
||||
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
|
||||
: anchorBounds.left;
|
||||
const leftOffset = anchorBounds.left - left;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position
|
||||
top={anchorBounds.bottom + window.scrollY}
|
||||
left={left}
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={cardRef}>
|
||||
<HoverPreviewDocument url={node.href}>
|
||||
{(content) =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
<Card>
|
||||
<Margin />
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
<Pointer offset={leftOffset + anchorBounds.width / 2} />
|
||||
</Animate>
|
||||
) : null
|
||||
}
|
||||
</HoverPreviewDocument>
|
||||
</div>
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ node, ...rest }: Props) {
|
||||
// previews only work for internal doc links for now
|
||||
if (!isInternalUrl(node.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} node={node} />;
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideIn} 150ms ease;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
// fills the gap between the card and pointer to avoid a dead zone
|
||||
const Margin = styled.div`
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 11px;
|
||||
`;
|
||||
|
||||
const CardContent = styled.div`
|
||||
overflow: hidden;
|
||||
max-height: 350px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
const Card = styled.div`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${(props) => props.theme.background};
|
||||
border: ${(props) =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
padding: 16px;
|
||||
width: 350px;
|
||||
font-size: 0.9em;
|
||||
position: relative;
|
||||
|
||||
.placeholder,
|
||||
.heading-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${(props) => transparentize(1, props.theme.background)} 0%,
|
||||
${(props) => transparentize(1, props.theme.background)} 75%,
|
||||
${(props) => props.theme.background} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1.7em;
|
||||
border-bottom: 16px solid ${(props) => props.theme.background};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${(props) => props.theme.depths.hoverPreview};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${(props) => props.theme.background};
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject("documents")(HoverPreview);
|
||||
@@ -0,0 +1,53 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
|
||||
import Editor from "components/Editor";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
children: (React.Node) => React.Node,
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, children }: Props) {
|
||||
const { documents } = useStores();
|
||||
const slug = parseDocumentSlug(url);
|
||||
|
||||
documents.prefetchDocument(slug, {
|
||||
prefetch: true,
|
||||
});
|
||||
|
||||
const document = slug ? documents.getByUrl(slug) : undefined;
|
||||
if (!document) return null;
|
||||
|
||||
return children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMetaWithViews isDraft={document.isDraft} document={document} />
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
disableEmbeds
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
margin: 0 0 0.75em;
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default observer(HoverPreviewDocument);
|
||||
@@ -0,0 +1,226 @@
|
||||
// @flow
|
||||
import {
|
||||
CollectionIcon,
|
||||
CoinsIcon,
|
||||
AcademicCapIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
CloudIcon,
|
||||
CodeIcon,
|
||||
EditIcon,
|
||||
EyeIcon,
|
||||
LeafIcon,
|
||||
LightBulbIcon,
|
||||
MoonIcon,
|
||||
NotepadIcon,
|
||||
PadlockIcon,
|
||||
PaletteIcon,
|
||||
QuestionMarkIcon,
|
||||
SunIcon,
|
||||
VehicleIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText } from "components/Input";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
const style = { width: 30, height: 30 };
|
||||
|
||||
const TwitterPicker = React.lazy(() =>
|
||||
import(
|
||||
/* webpackChunkName: "twitter-picker" */
|
||||
"react-color/lib/components/twitter/Twitter"
|
||||
)
|
||||
);
|
||||
|
||||
export const icons = {
|
||||
collection: {
|
||||
component: CollectionIcon,
|
||||
keywords: "collection",
|
||||
},
|
||||
coins: {
|
||||
component: CoinsIcon,
|
||||
keywords: "coins money finance sales income revenue cash",
|
||||
},
|
||||
academicCap: {
|
||||
component: AcademicCapIcon,
|
||||
keywords: "learn teach lesson guide tutorial onboarding training",
|
||||
},
|
||||
beaker: {
|
||||
component: BeakerIcon,
|
||||
keywords: "lab research experiment test",
|
||||
},
|
||||
buildingBlocks: {
|
||||
component: BuildingBlocksIcon,
|
||||
keywords: "app blocks product prototype",
|
||||
},
|
||||
cloud: {
|
||||
component: CloudIcon,
|
||||
keywords: "cloud service aws infrastructure",
|
||||
},
|
||||
code: {
|
||||
component: CodeIcon,
|
||||
keywords: "developer api code development engineering programming",
|
||||
},
|
||||
eye: {
|
||||
component: EyeIcon,
|
||||
keywords: "eye view",
|
||||
},
|
||||
leaf: {
|
||||
component: LeafIcon,
|
||||
keywords: "leaf plant outdoors nature ecosystem climate",
|
||||
},
|
||||
lightbulb: {
|
||||
component: LightBulbIcon,
|
||||
keywords: "lightbulb idea",
|
||||
},
|
||||
moon: {
|
||||
component: MoonIcon,
|
||||
keywords: "night moon dark",
|
||||
},
|
||||
notepad: {
|
||||
component: NotepadIcon,
|
||||
keywords: "journal notepad write notes",
|
||||
},
|
||||
padlock: {
|
||||
component: PadlockIcon,
|
||||
keywords: "padlock private security authentication authorization auth",
|
||||
},
|
||||
palette: {
|
||||
component: PaletteIcon,
|
||||
keywords: "design palette art brand",
|
||||
},
|
||||
pencil: {
|
||||
component: EditIcon,
|
||||
keywords: "copy writing post blog",
|
||||
},
|
||||
question: {
|
||||
component: QuestionMarkIcon,
|
||||
keywords: "question help support faq",
|
||||
},
|
||||
sun: {
|
||||
component: SunIcon,
|
||||
keywords: "day sun weather",
|
||||
},
|
||||
vehicle: {
|
||||
component: VehicleIcon,
|
||||
keywords: "truck car travel transport",
|
||||
},
|
||||
};
|
||||
|
||||
const colors = [
|
||||
"#4E5C6E",
|
||||
"#0366d6",
|
||||
"#9E5CF7",
|
||||
"#FF825C",
|
||||
"#FF5C80",
|
||||
"#FFBE0B",
|
||||
"#42DED1",
|
||||
"#00D084",
|
||||
"#FF4DFA",
|
||||
"#2F362F",
|
||||
];
|
||||
|
||||
type Props = {|
|
||||
onOpen?: () => void,
|
||||
onChange: (color: string, icon: string) => void,
|
||||
icon: string,
|
||||
color: string,
|
||||
|};
|
||||
|
||||
function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const Component = icons[icon || "collection"].component;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
</Label>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button aria-label={t("Show menu")} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
|
||||
<Icons>
|
||||
{Object.keys(icons).map((name) => {
|
||||
const Component = icons[name].component;
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
onClick={() => onChange(color, name)}
|
||||
{...menu}
|
||||
>
|
||||
{(props) => (
|
||||
<IconButton style={style} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Icons>
|
||||
<Flex>
|
||||
<React.Suspense fallback={<Loading>{t("Loading")}…</Loading>}>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</ContextMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const Icons = styled.div`
|
||||
padding: 15px 9px 9px 15px;
|
||||
width: 276px;
|
||||
`;
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
border: 1px solid ${(props) => props.theme.inputBorder};
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
const IconButton = styled(NudeButton)`
|
||||
border-radius: 4px;
|
||||
margin: 0px 6px 6px 0px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
`;
|
||||
|
||||
const Loading = styled(HelpText)`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const ColorPicker = styled(TwitterPicker)`
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
`;
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export default IconPicker;
|
||||
@@ -0,0 +1,15 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {|
|
||||
alt: string,
|
||||
src: string,
|
||||
title?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
|};
|
||||
|
||||
export default function Image({ src, alt, ...rest }: Props) {
|
||||
return <img src={cdnPath(src)} alt={alt} {...rest} />;
|
||||
}
|
||||
+52
-32
@@ -1,49 +1,55 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import styled from 'styled-components';
|
||||
import VisuallyHidden from 'components/VisuallyHidden';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px 8px ${props => (props.hasIcon ? '8px' : '12px')};
|
||||
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.placeholder};
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
const RealInput = styled.input`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px 8px ${props => (props.hasIcon ? '8px' : '12px')};
|
||||
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.placeholder};
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: searchfield-cancel-button;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
font-size: 16px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex: ${props => (props.flex ? '1' : '0')};
|
||||
max-width: ${props => (props.short ? '350px' : '100%')};
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : '0')};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : 'initial')};
|
||||
flex: ${(props) => (props.flex ? "1" : "0")};
|
||||
width: ${(props) => (props.short ? "49%" : "auto")};
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "initial")};
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
@@ -54,18 +60,18 @@ const IconWrapper = styled.span`
|
||||
`;
|
||||
|
||||
export const Outline = styled(Flex)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin: ${props => (props.margin !== undefined ? props.margin : '0 0 16px')};
|
||||
margin: ${(props) =>
|
||||
props.margin !== undefined ? props.margin : "0 0 16px"};
|
||||
color: inherit;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: ${props =>
|
||||
border-color: ${(props) =>
|
||||
props.hasError
|
||||
? 'red'
|
||||
? props.theme.danger
|
||||
: props.focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
align-items: center;
|
||||
@@ -75,10 +81,11 @@ export const Outline = styled(Flex)`
|
||||
export const LabelText = styled.div`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
export type Props = {|
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea",
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
@@ -87,9 +94,22 @@ export type Props = {
|
||||
short?: boolean,
|
||||
margin?: string | number,
|
||||
icon?: React.Node,
|
||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||
};
|
||||
name?: string,
|
||||
minLength?: number,
|
||||
maxLength?: number,
|
||||
autoFocus?: boolean,
|
||||
autoComplete?: boolean | string,
|
||||
readOnly?: boolean,
|
||||
required?: boolean,
|
||||
disabled?: boolean,
|
||||
placeholder?: string,
|
||||
onChange?: (
|
||||
ev: SyntheticInputEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => mixed,
|
||||
onKeyDown?: (ev: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
onFocus?: (ev: SyntheticEvent<>) => mixed,
|
||||
onBlur?: (ev: SyntheticEvent<>) => mixed,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
@@ -118,7 +138,7 @@ class Input extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const {
|
||||
type = 'text',
|
||||
type = "text",
|
||||
icon,
|
||||
label,
|
||||
margin,
|
||||
@@ -131,7 +151,7 @@ class Input extends React.Component<Props> {
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const InputComponent = type === 'textarea' ? RealTextarea : RealInput;
|
||||
const InputComponent = type === "textarea" ? RealTextarea : RealInput;
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
@@ -146,10 +166,10 @@ class Input extends React.Component<Props> {
|
||||
<Outline focused={this.focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<InputComponent
|
||||
ref={ref => (this.input = ref)}
|
||||
ref={(ref) => (this.input = ref)}
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
type={type === 'textarea' ? undefined : type}
|
||||
type={type === "textarea" ? undefined : type}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import Input from "./Input";
|
||||
|
||||
const InputLarge = styled(Input)`
|
||||
height: 38px;
|
||||
flex-grow: 1;
|
||||
margin-right: 8px;
|
||||
|
||||
input {
|
||||
height: 38px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default InputLarge;
|
||||
+21
-37
@@ -1,26 +1,26 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import { LabelText, Outline } from 'components/Input';
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Editor from "components/Editor";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText, Outline } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
label: string,
|
||||
minHeight?: number,
|
||||
maxHeight?: number,
|
||||
readOnly?: boolean,
|
||||
};
|
||||
ui: UiStore,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class InputRich extends React.Component<Props> {
|
||||
@observable editorComponent: React.ComponentType<any>;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
@@ -29,53 +29,37 @@ class InputRich extends React.Component<Props> {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
loadEditor = async () => {
|
||||
try {
|
||||
const EditorImport = await import('./Editor');
|
||||
this.editorComponent = EditorImport.default;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, minHeight, maxHeight, ...rest } = this.props;
|
||||
const Editor = this.editorComponent;
|
||||
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<LabelText>{label}</LabelText>
|
||||
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={this.focused}
|
||||
>
|
||||
{Editor ? (
|
||||
<React.Suspense fallback={<HelpText>Loading editor…</HelpText>}>
|
||||
<Editor
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
ui={ui}
|
||||
grow
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
'Loading…'
|
||||
)}
|
||||
</React.Suspense>
|
||||
</StyledOutline>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const StyledOutline = styled(Outline)`
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : '0')};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : 'auto')};
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "auto")};
|
||||
overflow-y: auto;
|
||||
|
||||
> * {
|
||||
@@ -83,4 +67,4 @@ const StyledOutline = styled(Outline)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default withTheme(InputRich);
|
||||
export default inject("ui")(withTheme(InputRich));
|
||||
|
||||
@@ -1,74 +1,48 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import keydown from 'react-keydown';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import styled, { withTheme } from 'styled-components';
|
||||
import { SearchIcon } from 'outline-icons';
|
||||
import { searchUrl } from 'utils/routeHelpers';
|
||||
import Input from './Input';
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Input, { type Props as InputProps } from "./Input";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
theme: Object,
|
||||
type Props = {|
|
||||
...InputProps,
|
||||
placeholder?: string,
|
||||
collectionId?: string,
|
||||
};
|
||||
value?: string,
|
||||
onChange: (event: SyntheticInputEvent<>) => mixed,
|
||||
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class InputSearch extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
export default function InputSearch(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
|
||||
@keydown('meta+f')
|
||||
focus(ev) {
|
||||
ev.preventDefault();
|
||||
const handleFocus = React.useCallback(() => {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
if (this.input) {
|
||||
this.input.focus();
|
||||
}
|
||||
}
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setIsFocused(false);
|
||||
}, []);
|
||||
|
||||
handleSearchInput = ev => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, this.props.collectionId)
|
||||
);
|
||||
};
|
||||
const { placeholder = `${t("Search")}…`, onKeyDown, ...rest } = props;
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { theme, placeholder = 'Search…' } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={ref => (this.input = ref)}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
onInput={this.handleSearchInput}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={isFocused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
`;
|
||||
|
||||
export default withTheme(withRouter(InputSearch));
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Input from "./Input";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import { searchUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
theme: Theme,
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
collectionId?: string,
|
||||
value: string,
|
||||
onChange: (event: SyntheticInputEvent<>) => mixed,
|
||||
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class InputSearchPage extends React.Component<Props> {
|
||||
input: ?Input;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
@keydown(`${meta}+f`)
|
||||
focus(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.input) {
|
||||
this.input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchInput = (ev: SyntheticInputEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, {
|
||||
collectionId: this.props.collectionId,
|
||||
ref: this.props.source,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, value, onChange, onKeyDown } = this.props;
|
||||
const { theme, placeholder = `${t("Search")}…` } = this.props;
|
||||
|
||||
return (
|
||||
<InputMaxWidth
|
||||
ref={(ref) => (this.input = ref)}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
onInput={this.handleSearchInput}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={
|
||||
<SearchIcon
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={this.props.label}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
labelHidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const InputMaxWidth = styled(Input)`
|
||||
max-width: 30vw;
|
||||
`;
|
||||
|
||||
export default withTranslation()<InputSearchPage>(
|
||||
withTheme(withRouter(InputSearchPage))
|
||||
);
|
||||
@@ -1,33 +1,47 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import styled from 'styled-components';
|
||||
import VisuallyHidden from 'components/VisuallyHidden';
|
||||
import { Outline, LabelText } from './Input';
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
padding: 4px 0;
|
||||
margin: 0 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${props => props.theme.text};
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
option {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${props => props.theme.placeholder};
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
type Option = { label: string, value: string };
|
||||
const Wrapper = styled.label`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
export type Option = { label: string, value: string };
|
||||
|
||||
export type Props = {
|
||||
value?: string,
|
||||
label?: string,
|
||||
short?: boolean,
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
options: Option[],
|
||||
onBlur?: () => void,
|
||||
onFocus?: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -43,12 +57,19 @@ class InputSelect extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, className, labelHidden, options, ...rest } = this.props;
|
||||
const {
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
options,
|
||||
short,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<label>
|
||||
<Wrapper short={short}>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
@@ -57,14 +78,14 @@ class InputSelect extends React.Component<Props> {
|
||||
))}
|
||||
<Outline focused={this.focused} className={className}>
|
||||
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
|
||||
{options.map(option => (
|
||||
{options.map((option) => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user