mirror of
https://github.com/docmost/docmost.git
synced 2026-05-14 20:54:07 +08:00
Compare commits
875 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2045a5ab3f | |||
| d22d2c8f5a | |||
| 52e5a87e9b | |||
| 70dc1c2a77 | |||
| 34ee23c889 | |||
| fc81a777a0 | |||
| 5c1ac234dc | |||
| 7fe2e1e666 | |||
| 89c5bcd303 | |||
| c2b50dc280 | |||
| dbabeba3eb | |||
| df1bd40a03 | |||
| d6c46339c3 | |||
| 5a0f5c400f | |||
| d7c75bb18e | |||
| ed696848fa | |||
| 24d7ab8d60 | |||
| 6f0f74a105 | |||
| 8ae5980dd0 | |||
| 9996753e88 | |||
| 8cc3a4abb1 | |||
| bb9ce2da53 | |||
| aef6af5376 | |||
| b6bd0c7189 | |||
| ce9190381c | |||
| 0e6944f52c | |||
| 6abd1b6aec | |||
| 964d60f520 | |||
| d856d95232 | |||
| 51f7017b58 | |||
| c498b20ab1 | |||
| ddf05fb44a | |||
| 129e2ce1e3 | |||
| 2f32f6e8b0 | |||
| 7f6229ac8f | |||
| 23d852da1e | |||
| 1a88b23394 | |||
| 371c796afd | |||
| 65f048e6c4 | |||
| da61d464b2 | |||
| 8eb8de6df7 | |||
| 10d922eb71 | |||
| ab4605c4ad | |||
| a95f3d254d | |||
| 5451efaf5d | |||
| 4ee65525c9 | |||
| d39ba67834 | |||
| 253c9a5d88 | |||
| cdf00a96a9 | |||
| 73b726c7a8 | |||
| d9d6a8e2ae | |||
| 3602860979 | |||
| 537e45bc11 | |||
| 79b79348ba | |||
| b44d6e3138 | |||
| cdd9c5cf66 | |||
| c866c07219 | |||
| 6638ca806c | |||
| adc2341029 | |||
| 27ebcd248f | |||
| 366b8fbdc1 | |||
| 44cba3c581 | |||
| 23c5bf6b66 | |||
| a6b49f49bd | |||
| 24a9403f09 | |||
| e8323708d7 | |||
| b9d28223d9 | |||
| 57ca56744d | |||
| f45c6bead3 | |||
| e02cdf6d59 | |||
| 0f6d0fb440 | |||
| 7025e3c947 | |||
| 53a803383b | |||
| 1d5b5a3002 | |||
| 08a9df37ef | |||
| 63be9a58c1 | |||
| bdc369fce0 | |||
| 2d8b470495 | |||
| c66c08fa78 | |||
| 6046d04375 | |||
| 5d8c11e741 | |||
| de60aa7e61 | |||
| c9fa6e20b3 | |||
| ec51ca7815 | |||
| 2b63137217 | |||
| 696aa430f4 | |||
| ed9d85ca95 | |||
| d992abf8d8 | |||
| 149bcc14f1 | |||
| 8ed4f4b7aa | |||
| fd0200331c | |||
| 44ab6e5485 | |||
| 505d7923db | |||
| 3112709d03 | |||
| 8c9d0389f4 | |||
| afafc8451f | |||
| 0a4bbd5d30 | |||
| 3227bc6059 | |||
| 73dc62bca3 | |||
| c802222562 | |||
| be2d93877a | |||
| 34da8d3fb4 | |||
| f55bd21b08 | |||
| f6f9cc14df | |||
| 1790ee8f6f | |||
| 58840da7f4 | |||
| db77b31782 | |||
| 77ba4facb1 | |||
| 6fa08e487e | |||
| 979e8faeee | |||
| 1839418430 | |||
| 3c74bb3dee | |||
| dbe6c2d6ba | |||
| fe18f22dc6 | |||
| fcef0c6b96 | |||
| 17f3158a3b | |||
| 94461e90a3 | |||
| 58aa02340e | |||
| 592e6a39e8 | |||
| 56526c6c1c | |||
| 6f9387b8b4 | |||
| aa2ca3ef91 | |||
| 21848b91bf | |||
| 989231d818 | |||
| d50986453b | |||
| 2c21af4e91 | |||
| 574f687335 | |||
| 9956a98d1f | |||
| b74ca00bfd | |||
| c247d4c1e3 | |||
| 641ce142df | |||
| 1d2486455f | |||
| a0aea43e25 | |||
| 09c69d7a0f | |||
| 14fd3eb956 | |||
| 9943e104a5 | |||
| b16f1e5a55 | |||
| 24be90b95f | |||
| 3ecf27c6b0 | |||
| 980521f957 | |||
| fe44dc92a9 | |||
| fad410ef23 | |||
| 15b8908b1a | |||
| 8e15b22d8c | |||
| ec83fc82d5 | |||
| a573acedd0 | |||
| dba8e315ab | |||
| 81ae7a17a6 | |||
| 271f855761 | |||
| 3e6d915227 | |||
| a6a7e4370a | |||
| cc00e77dfb | |||
| 66c70c0e76 | |||
| 0e8b3bbfb3 | |||
| a3a9f35005 | |||
| 4056bd0104 | |||
| bd68e47e03 | |||
| d6068310b4 | |||
| e02661974e | |||
| 1113f17a43 | |||
| d42091ccb1 | |||
| 57efb91bd3 | |||
| da9b43681e | |||
| 4966f9b152 | |||
| e1bbceb9a6 | |||
| 895c1817ae | |||
| 642024ba9d | |||
| 147d028036 | |||
| 992691e6e0 | |||
| 9aaa6c731c | |||
| fd91b11c6c | |||
| af8b0ddf3a | |||
| 879aa2c3d8 | |||
| c180d0e487 | |||
| a062f7a165 | |||
| cbd0dd4a0b | |||
| 2d6d829581 | |||
| 5cea30cc5c | |||
| bca85a49d6 | |||
| c9cdfa0f17 | |||
| 412962204c | |||
| a42ac3d450 | |||
| 642c92f779 | |||
| ccb35517bb | |||
| cbdb37ed0a | |||
| aa27d57624 | |||
| 3829b6cbef | |||
| 17da762984 | |||
| 859f16740b | |||
| 7981ef462e | |||
| 2d835da0e3 | |||
| a3559b7c33 | |||
| 803f1f0b81 | |||
| 4e8f533b91 | |||
| 7b0d8fe140 | |||
| 2f92278a9d | |||
| 53608eae35 | |||
| 0e4a1e7419 | |||
| 9125996e97 | |||
| fa4872e89e | |||
| 6d6f3a8a8e | |||
| 975b4dcaab | |||
| 6683c515cf | |||
| cc5c800238 | |||
| cfaee93af9 | |||
| 74eddb0638 | |||
| 7c83a9d4f0 | |||
| 2678c4e279 | |||
| b0bde4b375 | |||
| 724e37d5b7 | |||
| 33184e9d8d | |||
| 7520c329d0 | |||
| d7a5fda53c | |||
| 236a63dadc | |||
| 89b94e5d19 | |||
| 97c459be67 | |||
| d0ed6865cb | |||
| 65b89a1b24 | |||
| 1fdee33206 | |||
| 7b69727a30 | |||
| 66c26af34b | |||
| b4f009513e | |||
| fcffa3dfa0 | |||
| 1980b94825 | |||
| bea1637519 | |||
| 37355452e1 | |||
| 057360c6be | |||
| f12bfc1ff7 | |||
| f5d794220e | |||
| a3c1c6cccd | |||
| 4b105586a9 | |||
| d2641db895 | |||
| 1111df65cd | |||
| e455154b7d | |||
| ef24b3c07d | |||
| 2352f3c5d9 | |||
| 568dd4c321 | |||
| b6478fee84 | |||
| 5d2aad3668 | |||
| 9331ac2df8 | |||
| 9f4728e279 | |||
| 628b08339a | |||
| 68842dbea2 | |||
| b1510cd6d7 | |||
| af92224e10 | |||
| c24ff44e09 | |||
| 90c190df78 | |||
| 17ec2f4ac5 | |||
| 9881c53f00 | |||
| 721651e2e2 | |||
| a3fd79dae8 | |||
| 616d9297eb | |||
| ee6b98edaa | |||
| cf43e2b4fe | |||
| 614baf153b | |||
| 4f3577f009 | |||
| d5e4b8bb59 | |||
| 1a897faaa2 | |||
| 6f1a91cc05 | |||
| 60848ea903 | |||
| 2309d1434b | |||
| dcc2bacb22 | |||
| 69d7532c6c | |||
| 85ce0d32bf | |||
| fc0997fd90 | |||
| df64de5306 | |||
| ea44468fad | |||
| 59e945562d | |||
| 22f33bab7c | |||
| e0a8521566 | |||
| b5803f42da | |||
| 5de1c8e3ed | |||
| ef87210b3d | |||
| c172d3bd5e | |||
| 53132acb0a | |||
| d6472f0876 | |||
| 873c963043 | |||
| 03a70d768a | |||
| 0aeaa43112 | |||
| 92d5d0b237 | |||
| 0ce74d34de | |||
| 00b5328676 | |||
| 2ebdc2baea | |||
| 621ef4f0cf | |||
| 26b9338da5 | |||
| 618f56577d | |||
| 0a05ce6133 | |||
| cb9d6be3b9 | |||
| b76f5adaad | |||
| 41fa77b29d | |||
| 05b3c65b0f | |||
| e0ab9d9b5e | |||
| 55280db672 | |||
| 32bbc6911f | |||
| 5814542128 | |||
| 18b5781522 | |||
| 49ab9875ba | |||
| 25f4b8c2b4 | |||
| 4d43f86c51 | |||
| f170ede8da | |||
| 7861b5b186 | |||
| 3a9bdfbb06 | |||
| ab7999a946 | |||
| 0f02261ee6 | |||
| aff8dba2cb | |||
| f6a8247c48 | |||
| 7879e1f600 | |||
| 3cb70f0696 | |||
| fbb44df548 | |||
| bc3ce893c4 | |||
| ae96352189 | |||
| 1ad53c2581 | |||
| 2f97a3debc | |||
| 40b5346f9e | |||
| d6b4573b79 | |||
| 4878850b25 | |||
| 5c3942c159 | |||
| e0809e7104 | |||
| da6793ac87 | |||
| 08e94eb3c1 | |||
| 5a14186f1c | |||
| 6a0bb8d4cb | |||
| fba9f4cb2b | |||
| d8f7c4a822 | |||
| 202685b39f | |||
| fc4a428208 | |||
| 5506eb194b | |||
| f32bb298e0 | |||
| 3178cad796 | |||
| 9d7f8c62c5 | |||
| 78b1c1a453 | |||
| 96ed98619f | |||
| 60501de992 | |||
| 74e915546b | |||
| 3523600f40 | |||
| 6ccb2bb872 | |||
| 0245a183e1 | |||
| de5f71894a | |||
| 351b075ebb | |||
| 1ca7d42203 | |||
| 1e441560f6 | |||
| 54775f537d | |||
| 5dbf0027bd | |||
| 5588ec34fb | |||
| 55b8128829 | |||
| aa6a046aa6 | |||
| 657fdf8cb7 | |||
| 98f71c95fe | |||
| efb0a9317b | |||
| 063ea99b66 | |||
| aa143ad79c | |||
| 918f4508d2 | |||
| 5cd0ba6902 | |||
| a1260188ae | |||
| bdf02f593d | |||
| e24bf5ed57 | |||
| f3f74c591f | |||
| 5f966a2d89 | |||
| bcb004af21 | |||
| ac675e7d74 | |||
| bf89eff5e7 | |||
| 183787fa0c | |||
| 15aa04a5f7 | |||
| 79343a5d52 | |||
| 61e252918e | |||
| e98fa7f69a | |||
| 6d148a35eb | |||
| 0bbc1c35de | |||
| 47097969a0 | |||
| 13f529e064 | |||
| 8fc8422fbc | |||
| 732951a322 | |||
| 2544775266 | |||
| d59539f197 | |||
| b061df7f7d | |||
| 0fe1459864 | |||
| 6af7956889 | |||
| 3dbb957bd7 | |||
| f39a4cf2d5 | |||
| 724e01bd55 | |||
| 6e350f6746 | |||
| cb9f27da9a | |||
| d2629afff2 | |||
| 9139d393ef | |||
| ab96672ecd | |||
| 2ea3c2da58 | |||
| 9fb16bc842 | |||
| c3b350d943 | |||
| 8014ba3ab7 | |||
| ec3a04f7c7 | |||
| 04a17c9b92 | |||
| 520c07a0bc | |||
| 60a8ed6826 | |||
| f5684b792e | |||
| 042836cb6d | |||
| 4f1f0ba513 | |||
| 3164b6981c | |||
| 16c1e864af | |||
| c9b1cad982 | |||
| bf8cf6254f | |||
| 3135030376 | |||
| 3fae41a5ca | |||
| b50e25600a | |||
| 1f3b0c7276 | |||
| 3c4cab0d2a | |||
| 4de25a8b94 | |||
| cf5bbb10df | |||
| ac17521717 | |||
| 9ac180f719 | |||
| 46669fea56 | |||
| fe6ecdf1f1 | |||
| 04ae1d7270 | |||
| 1280f96f37 | |||
| 61d1cf88a7 | |||
| f413720e15 | |||
| 8e16ad952a | |||
| 7ada3cb1f9 | |||
| 47c54174b3 | |||
| dc0650289d | |||
| 091e790b83 | |||
| ae24ea29ba | |||
| 9df6061e1a | |||
| 31053e2b20 | |||
| eb8e8507ea | |||
| c99bfb8ef1 | |||
| 26ea04e2a3 | |||
| 6cc58c57f5 | |||
| 7d2ff346fa | |||
| b08d37fbf0 | |||
| d43ee77617 | |||
| 5d91eb4f5f | |||
| 3e9f6b11cc | |||
| db55de9406 | |||
| 1919eba340 | |||
| 7951b2e0c6 | |||
| 73b78f625d | |||
| cf7534de3d | |||
| adec36d544 | |||
| f9e10805f0 | |||
| 00e499b3e5 | |||
| 5ee6e46535 | |||
| 1f797c3d27 | |||
| f12866cf42 | |||
| dcbb65d799 | |||
| 5968764508 | |||
| 242fb6bb57 | |||
| 74cd890bdd | |||
| 509622af54 | |||
| 937386e42b | |||
| 60a373f488 | |||
| 73ee6ee8c3 | |||
| 7d1e5bce0d | |||
| aa58e272d6 | |||
| 08135a2fba | |||
| d92a94244f | |||
| 5012a68d85 | |||
| 5a3377790e | |||
| 3b85f4b616 | |||
| cb2a0398c7 | |||
| 95b7be61df | |||
| b0c557272d | |||
| dddfd48934 | |||
| aa6eec754e | |||
| 97a7701f5d | |||
| b97eb85d05 | |||
| 1615e0f4ad | |||
| 1cb2535de3 | |||
| 83bc273cb0 | |||
| c7beaa3742 | |||
| 4a228e5a51 | |||
| edff375476 | |||
| 95016b2bfc | |||
| ca83712364 | |||
| 39550fe906 | |||
| e74ecb2604 | |||
| 992fb23160 | |||
| d58a3bba9b | |||
| 6ef47fc432 | |||
| 9e6765d83c | |||
| ec0ed5c630 | |||
| 77b334ea37 | |||
| 5da92a538a | |||
| f90c5a636b | |||
| 6db93ef0c7 | |||
| a3d058042f | |||
| 4ab9261cf5 | |||
| ca9558b246 | |||
| ec12e80423 | |||
| 28fcb11cb4 | |||
| 6b627d289c | |||
| 78bce0e29d | |||
| 0bd7ecb9b0 | |||
| 1f815880a4 | |||
| 37b9056070 | |||
| ad5cf1e18b | |||
| 32c7ecd9cf | |||
| b30bf61dc4 | |||
| 662460252f | |||
| 8522844673 | |||
| f8dc9845a7 | |||
| 4dfed2b2af | |||
| 44e592763d | |||
| 90488a95b1 | |||
| 9f39987404 | |||
| 16ec218ba7 | |||
| 608783b5cf | |||
| 5f5f1484db | |||
| f4082171ec | |||
| 6792a191b1 | |||
| e51a93221c | |||
| e856c8eb69 | |||
| c2c165528b | |||
| 9fa2b9636c | |||
| 29388636bf | |||
| f80004817c | |||
| ac79a185de | |||
| 27a9c0ebe4 | |||
| 81ffa6f459 | |||
| 5364702b69 | |||
| 232cea8cc9 | |||
| b9643d3584 | |||
| 9f144d35fb | |||
| e44c170873 | |||
| 1be39d4353 | |||
| 36d028ef4d | |||
| f5a36c60e8 | |||
| d5b84ae0b8 | |||
| e775e4dd8c | |||
| 65b01038d7 | |||
| e07cb57b01 | |||
| 2b53e0a455 | |||
| b9b3406b28 | |||
| 728cac0a34 | |||
| d35e16010b | |||
| 15791d4e59 | |||
| 3318e13225 | |||
| 080900610d | |||
| d1dc6977ab | |||
| 5f62448894 | |||
| 44445fbf46 | |||
| 1c674efddd | |||
| ccf7e34e99 | |||
| f39d48d6ee | |||
| f584ea84b0 | |||
| bc0c4d6258 | |||
| d8da307a61 | |||
| 50b3f9ddd9 | |||
| 0029f84d50 | |||
| 6d024fc3de | |||
| ce1503af85 | |||
| 69447fc375 | |||
| 858ff9da06 | |||
| 343b2976c2 | |||
| 7491224d0f | |||
| 4a0b4040ed | |||
| e3ba817723 | |||
| b0491d5da4 | |||
| 1c200dbd0f | |||
| fb7e4a7956 | |||
| 1413033568 | |||
| 00f4588c21 | |||
| 3a75251e75 | |||
| c6bca6a602 | |||
| 55d1a2c932 | |||
| bc3cb2d63f | |||
| 7adbf85030 | |||
| de7982fe30 | |||
| 0402f7efb5 | |||
| 8327251ab6 | |||
| e8847bd9cd | |||
| 9bbd62e0f0 | |||
| 0289c5cb09 | |||
| 7993532111 | |||
| 31e5c0c660 | |||
| 33c314d4e8 | |||
| 08f223899a | |||
| c528f7e858 | |||
| c26a851d52 | |||
| de5f90309c | |||
| 0ec3ff2965 | |||
| acffeacdbc | |||
| 00d92a3690 | |||
| 3430f715ec | |||
| 6c422011ac | |||
| 3e8824435d | |||
| 37a1804db9 | |||
| 882f3093bd | |||
| 1a1b2c8682 | |||
| 10b67929ea | |||
| 5c957fda8d | |||
| 862f6d4820 | |||
| de57d05199 | |||
| 89ec990232 | |||
| 49d0f1cc9a | |||
| 268001ae26 | |||
| 27fa45a769 | |||
| f9711918a3 | |||
| 29bb52db0c | |||
| f2241db5ee | |||
| 58d1855a36 | |||
| 7fe3c5f177 | |||
| 5fd477d074 | |||
| 4aa5d7e326 | |||
| 7f7f2bccd0 | |||
| a9f370660b | |||
| 117c7049ff | |||
| cd10365f71 | |||
| ee30d9d0f2 | |||
| 276ececbf2 | |||
| fa194a497c | |||
| 1eaba6e77f | |||
| 651e5f6153 | |||
| 7431804a46 | |||
| 3559358d14 | |||
| 06270ff747 | |||
| 233536314f | |||
| 17ce3bab8a | |||
| b27d1708b0 | |||
| 64f0531093 | |||
| 8aa604637e | |||
| 7ca2b437d4 | |||
| 595bd1dc81 | |||
| a74d3feae4 | |||
| e40faf97ec | |||
| bbe4fe99f9 | |||
| 8300c5b731 | |||
| 13039cfacc | |||
| 593f41a050 | |||
| f8ce160906 | |||
| c824b5b570 | |||
| 37e760d76c | |||
| 442fa23399 | |||
| 2e5990d057 | |||
| 15bdbf74cd | |||
| 3d9a7d808b | |||
| f45bdddb23 | |||
| 21c3ad0ecc | |||
| 573457403e | |||
| d021d0a38f | |||
| 96dfe9f817 | |||
| 598361992e | |||
| 210d1474ea | |||
| 5f520689ed | |||
| 2a535de29d | |||
| f45d9dc5a0 | |||
| f7a14e23cd | |||
| 1f40e9b960 | |||
| fea6518352 | |||
| 061a02ce51 | |||
| 2205ce0c3b | |||
| a812cdcf15 | |||
| 30acc6676a | |||
| 5c9e0a2630 | |||
| fd36076ae7 | |||
| dd52eb15ca | |||
| 6776e073b6 | |||
| 7a47da9273 | |||
| e62bc6c250 | |||
| 4f9e588494 | |||
| 05a3dfa26d | |||
| 8826cca539 | |||
| 1988feb9ce | |||
| e9b7273489 | |||
| 315afd6818 | |||
| 93ea31feb0 | |||
| 3b4e414c97 | |||
| d925c95fc9 | |||
| 4511db1526 | |||
| 56d9e46fd3 | |||
| cdea149ce7 | |||
| 16254802e3 | |||
| a7dd9b9198 | |||
| b81c9ee10c | |||
| 91596be70e | |||
| 72f64e7b10 | |||
| 3cfb17bb62 | |||
| fe5066c7b5 | |||
| e13be904cd | |||
| fda5c7d60f | |||
| 7fc1a782a7 | |||
| 54d27af76a | |||
| 0065f29634 | |||
| 7d034e8a8b | |||
| 81b6c7ef69 | |||
| 89f6b0a8c2 | |||
| ad1571b902 | |||
| 4b9ab4f63c | |||
| 08829ea721 | |||
| 6c502b4749 | |||
| 6b41538b60 | |||
| 496f5d7384 | |||
| 32c7a16d06 | |||
| 64ecef09bc | |||
| 3e5cb92621 | |||
| fd5ad2f576 | |||
| 74a5360561 | |||
| 7580e8d1fe | |||
| f92d63261d | |||
| 4d51986250 | |||
| e209aaa272 | |||
| 0ef6b1978a | |||
| ae842f94d0 | |||
| 7121771f92 | |||
| 040d6625df | |||
| 33ddd92198 | |||
| 54e8d60840 | |||
| db986038c2 | |||
| de0b5f0046 | |||
| 638b811857 | |||
| d775a61c95 | |||
| 0f74f03264 | |||
| f8b93ce93f | |||
| 85d18b8cc8 | |||
| 4d9fe6f804 | |||
| 85159a2c95 | |||
| 990612793f | |||
| f2235fd2a2 | |||
| 2044cbb21c | |||
| 3d52b82cd4 | |||
| 89a2dd602b | |||
| 3cb954db69 | |||
| 71cfe3cd8e | |||
| f7efb6c2c9 | |||
| 59b514fa26 | |||
| 0c1f9304f4 | |||
| e876214eeb | |||
| 5fece5fc68 | |||
| f3dbf7cc5d | |||
| f7ac6bb4bb | |||
| 1f5ffe7f9d | |||
| 95715421c6 | |||
| f5bc99b449 | |||
| 287b833838 | |||
| 0cbbcb8eb1 | |||
| 670ee64179 | |||
| 290b7d9d94 | |||
| 2503bfd3a2 | |||
| f48d6dd60b | |||
| 1302b1b602 | |||
| 89a3f4cfc2 | |||
| e48b1c0dae | |||
| 4a2a5a7a4d | |||
| 532001fd82 | |||
| e6bf4cdd6c | |||
| a9a4a26db5 | |||
| ede5633415 | |||
| a25cf84671 | |||
| a37d558bac | |||
| ddb0f9225f | |||
| c717847ca8 | |||
| fe83557767 | |||
| 9fa432dba9 | |||
| c6aaefecbd | |||
| 311d81bc71 | |||
| f178e6654f | |||
| ca186f3c0e | |||
| a16d5d1bf4 | |||
| d97baf5824 | |||
| 8349d8271c | |||
| 2e6d16dbc3 | |||
| 4107793e73 | |||
| a1b6ac7f3e | |||
| dd0319a14d | |||
| 8194c7d42d | |||
| d01ced078b | |||
| da9c971050 | |||
| 4e7af507c6 | |||
| f7426a0b45 | |||
| b85b34d6b1 | |||
| e064e58f79 | |||
| 4f1a97ceb9 | |||
| d07338861b | |||
| 95159625aa | |||
| 9e0fbae1de | |||
| a52c86a180 | |||
| 31feb38def | |||
| ba32e42ece | |||
| a574d13f43 | |||
| ab70cee278 | |||
| 978fadd6b9 | |||
| b57be9c736 | |||
| d4b219d608 | |||
| 36e720920b | |||
| fa3c8a03e1 | |||
| 46d92fbabc | |||
| e17b975aaa | |||
| 038d21b438 | |||
| 078361b367 | |||
| 384f11f2b7 | |||
| e333eee08b | |||
| 7ec6a36515 | |||
| 2721ab6a29 | |||
| a2bc374f47 | |||
| eaa80a5546 | |||
| e9e668bd39 | |||
| 9390b39e35 | |||
| 2ae3816324 | |||
| e96330afbf | |||
| e56f7933f4 | |||
| b152c858b4 | |||
| e43ea66442 | |||
| f34812653e | |||
| 6a3a7721be | |||
| fb27282886 | |||
| dea9f4c063 | |||
| 0b6730c06f | |||
| be0d97661a | |||
| 4e2b23c97e | |||
| dc3ce27762 | |||
| 8af2d4e8cf | |||
| 73ddec4ca7 | |||
| 2b9765fb35 | |||
| 7fdd355cc3 | |||
| 6c6b47599a | |||
| 7e6a71fa2d | |||
| 1141796f24 | |||
| 11dbc079be | |||
| 87b99f8646 | |||
| 38e9eef2dc | |||
| 77b541ec71 | |||
| 7dc37b933f | |||
| 7e80797e3f | |||
| 17475bf123 | |||
| 4433d5174d | |||
| c810d0b314 | |||
| 463480ae67 | |||
| 2449d69fab | |||
| e0d74fcb0e | |||
| 4967849e3a | |||
| 0a447e91bb | |||
| 48e76aa9f4 | |||
| 2bd6422a35 | |||
| 407a1aff3b | |||
| b4bc184cb3 | |||
| 109dbdbe02 | |||
| 2df7de5828 | |||
| 373fc86e47 | |||
| 5052a9ea40 | |||
| cd47c79d86 | |||
| 78746938b7 | |||
| 4d2936627c | |||
| d2ecd28047 | |||
| bb92ca75e9 | |||
| 8f3e2ff663 | |||
| 89f6311e46 | |||
| e5a97d2a26 | |||
| 7f0fd45f3a | |||
| 078959dfa0 | |||
| 937a07059a | |||
| 227ac30d5e | |||
| a2ae341934 | |||
| 3c70e40d16 | |||
| 14197d7365 | |||
| f388540293 | |||
| b43de81013 | |||
| 6659adc7fe | |||
| 24adff9679 | |||
| e960b8c1a9 | |||
| 1958067110 | |||
| 3e519ebcd8 | |||
| 07cd650205 | |||
| 949d782a28 | |||
| 295d4325bf | |||
| 40a40bb3c7 | |||
| bc1579b022 | |||
| 85b3073681 | |||
| 4af3a54649 | |||
| c4c169b17a | |||
| a0536d852f | |||
| f12f93b373 | |||
| 35dcd5f254 | |||
| 9496ec9b57 | |||
| 5ace7616d0 | |||
| ce6a05ab66 | |||
| 66773dfaca |
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.git
|
.git
|
||||||
.gitignore
|
|
||||||
dist
|
dist
|
||||||
data
|
/data
|
||||||
|
.env*
|
||||||
|
.nx
|
||||||
|
|||||||
+23
-1
@@ -2,7 +2,7 @@
|
|||||||
APP_URL=http://localhost:3000
|
APP_URL=http://localhost:3000
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# make sure to replace this.
|
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
||||||
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
||||||
|
|
||||||
JWT_TOKEN_EXPIRES_IN=30d
|
JWT_TOKEN_EXPIRES_IN=30d
|
||||||
@@ -19,6 +19,10 @@ AWS_S3_SECRET_ACCESS_KEY=
|
|||||||
AWS_S3_REGION=
|
AWS_S3_REGION=
|
||||||
AWS_S3_BUCKET=
|
AWS_S3_BUCKET=
|
||||||
AWS_S3_ENDPOINT=
|
AWS_S3_ENDPOINT=
|
||||||
|
AWS_S3_FORCE_PATH_STYLE=
|
||||||
|
|
||||||
|
# default: 50mb
|
||||||
|
FILE_UPLOAD_SIZE_LIMIT=
|
||||||
|
|
||||||
# options: smtp | postmark
|
# options: smtp | postmark
|
||||||
MAIL_DRIVER=smtp
|
MAIL_DRIVER=smtp
|
||||||
@@ -30,7 +34,25 @@ SMTP_HOST=127.0.0.1
|
|||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_IGNORETLS=false
|
||||||
|
|
||||||
# Postmark driver config
|
# Postmark driver config
|
||||||
POSTMARK_TOKEN=
|
POSTMARK_TOKEN=
|
||||||
|
|
||||||
|
# for custom drawio server
|
||||||
|
DRAWIO_URL=
|
||||||
|
|
||||||
|
# Gotenberg URL for server-side PDF export
|
||||||
|
GOTENBERG_URL=
|
||||||
|
|
||||||
|
DISABLE_TELEMETRY=false
|
||||||
|
|
||||||
|
# Enable debug logging in production (default: false)
|
||||||
|
DEBUG_MODE=false
|
||||||
|
|
||||||
|
# Log database queries
|
||||||
|
DEBUG_DB=false
|
||||||
|
|
||||||
|
# Log http requests
|
||||||
|
LOG_HTTP=false
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version tag (e.g. v0.25.3)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version || github.ref_name }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
suffix: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
suffix: arm64
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
steps:
|
||||||
|
- name: Generate token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.BUILD_APP_ID }}
|
||||||
|
private-key: ${{ secrets.BUILD_APP_PRIVATE_KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
|
||||||
|
- name: Checkout with submodules
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
outputs: type=image,name=docmost/docmost,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||||
|
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.suffix }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Strip v prefix
|
||||||
|
id: strip-v
|
||||||
|
run: echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Export Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
push: false
|
||||||
|
tags: |
|
||||||
|
docmost/docmost:latest
|
||||||
|
docmost/docmost:${{ steps.strip-v.outputs.version }}
|
||||||
|
outputs: type=docker,dest=docmost-${{ matrix.suffix }}.docker.tar
|
||||||
|
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||||
|
|
||||||
|
- name: Compress image
|
||||||
|
run: gzip docmost-${{ matrix.suffix }}.docker.tar
|
||||||
|
|
||||||
|
- name: Upload image archive
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: docker-image-${{ matrix.suffix }}
|
||||||
|
path: docmost-${{ matrix.suffix }}.docker.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: digest-*
|
||||||
|
path: /tmp/digests
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: docmost/docmost
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }},enable=${{ !contains(env.VERSION, '-') }}
|
||||||
|
type=raw,value=latest,enable=${{ !contains(env.VERSION, '-') }}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf 'docmost/docmost@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Download image archives
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: docker-image-*
|
||||||
|
path: /tmp/images
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.VERSION }}
|
||||||
|
files: |
|
||||||
|
/tmp/images/docmost-amd64.docker.tar.gz
|
||||||
|
/tmp/images/docmost-arm64.docker.tar.gz
|
||||||
|
draft: true
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
|
.env.dev
|
||||||
|
.env.prod
|
||||||
data
|
data
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "apps/server/src/ee"]
|
||||||
|
path = apps/server/src/ee
|
||||||
|
url = https://github.com/docmost/ee
|
||||||
+10
-4
@@ -1,4 +1,7 @@
|
|||||||
FROM node:21-alpine AS base
|
FROM node:22-slim AS base
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@10.4.0
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
@@ -6,13 +9,14 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
|
|
||||||
RUN apk add --no-cache curl bash
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl bash \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -28,8 +32,10 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
|
|||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
COPY --from=builder /app/pnpm*.yaml /app/
|
COPY --from=builder /app/pnpm*.yaml /app/
|
||||||
|
COPY --from=builder /app/.npmrc /app/.npmrc
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
# Copy patches
|
||||||
|
COPY --from=builder /app/patches /app/patches
|
||||||
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
|||||||
@@ -4,32 +4,59 @@
|
|||||||
Open-source collaborative wiki and documentation software.
|
Open-source collaborative wiki and documentation software.
|
||||||
<br />
|
<br />
|
||||||
<a href="https://docmost.com"><strong>Website</strong></a> |
|
<a href="https://docmost.com"><strong>Website</strong></a> |
|
||||||
<a href="https://docmost.com/docs"><strong>Documentation</strong></a>
|
<a href="https://docmost.com/docs"><strong>Documentation</strong></a> |
|
||||||
|
<a href="https://twitter.com/DocmostHQ"><strong>Twitter / X</strong></a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Docmost is currently in **beta**. We value your feedback as we progress towards a stable release.
|
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs).
|
|
||||||
|
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs) or try our [cloud version](https://docmost.com/pricing) .
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Real-time collaboration
|
- Real-time collaboration
|
||||||
|
- Diagrams (Draw.io, Excalidraw and Mermaid)
|
||||||
- Spaces
|
- Spaces
|
||||||
- Permissions management
|
- Permissions management
|
||||||
- Groups
|
- Groups
|
||||||
- Comments
|
- Comments
|
||||||
- Page history
|
- Page history
|
||||||
- Search
|
- Search
|
||||||
- File attachment
|
- File attachments
|
||||||
|
- Embeds (Airtable, Loom, Miro and more)
|
||||||
|
- Translations (10+ languages)
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
#### Screenshots
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="home" src="https://docmost.com/screenshots/home.png" width="70%">
|
<img alt="home" src="https://docmost.com/screenshots/home.png" width="70%">
|
||||||
<img alt="editor" src="https://docmost.com/screenshots/editor.png" width="70%">
|
<img alt="editor" src="https://docmost.com/screenshots/editor.png" width="70%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
### License
|
||||||
|
Docmost core is licensed under the open-source AGPL 3.0 license.
|
||||||
|
Enterprise features are available under an enterprise license (Enterprise Edition).
|
||||||
|
|
||||||
|
All files in the following directories are licensed under the Docmost Enterprise license defined in `packages/ee/License`.
|
||||||
|
- apps/server/src/ee
|
||||||
|
- apps/client/src/ee
|
||||||
|
- packages/ee
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
See the [development doc](https://docmost.com/docs/self-hosting/development)
|
|
||||||
|
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
Special thanks to;
|
||||||
|
|
||||||
|
<img width="100" alt="Crowdin" src="https://github.com/user-attachments/assets/a6c3d352-e41b-448d-b6cd-3fbca3109f07" />
|
||||||
|
|
||||||
|
[Crowdin](https://crowdin.com/) for providing access to their localization platform.
|
||||||
|
|
||||||
|
|
||||||
|
<img width="48" alt="Algolia-mark-square-white" src="https://github.com/user-attachments/assets/6ccad04a-9589-4965-b6a1-d5cb1f4f9e94" />
|
||||||
|
|
||||||
|
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
'plugin:@tanstack/eslint-plugin-query/recommended',
|
|
||||||
],
|
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['react-refresh'],
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import pluginQuery from "@tanstack/eslint-plugin-query";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
"@tanstack/query": pluginQuery,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"@typescript-eslint/no-unused-expressions": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
+12
-3
@@ -2,10 +2,19 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
|
||||||
<title>Docmost</title>
|
<title>Docmost</title>
|
||||||
|
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-touch-fullscreen" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Docmost" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<!--meta-tags-->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+60
-43
@@ -1,67 +1,84 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.3",
|
"version": "0.80.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "^6.7.1",
|
"@casl/react": "^5.0.1",
|
||||||
"@casl/react": "^4.0.0",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@mantine/core": "^7.11.0",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/form": "^7.11.0",
|
"@mantine/core": "^8.3.18",
|
||||||
"@mantine/hooks": "^7.11.0",
|
"@mantine/dates": "^8.3.18",
|
||||||
"@mantine/modals": "^7.11.0",
|
"@mantine/form": "^8.3.18",
|
||||||
"@mantine/notifications": "^7.11.0",
|
"@mantine/hooks": "^8.3.18",
|
||||||
"@mantine/spotlight": "^7.11.0",
|
"@mantine/modals": "^8.3.18",
|
||||||
"@tabler/icons-react": "^3.7.0",
|
"@mantine/notifications": "^8.3.18",
|
||||||
"@tanstack/react-query": "^5.48.0",
|
"@mantine/spotlight": "^8.3.18",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.4.0",
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"axios": "^1.7.2",
|
"@tanstack/react-query": "5.90.17",
|
||||||
|
"alfaaz": "^1.1.0",
|
||||||
|
"axios": "1.16.0",
|
||||||
|
"blueimp-load-image": "^5.16.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"jotai": "^2.8.3",
|
"file-saver": "^2.0.5",
|
||||||
|
"highlightjs-sap-abap": "^0.3.0",
|
||||||
|
"i18next": "25.10.1",
|
||||||
|
"i18next-http-backend": "3.0.6",
|
||||||
|
"jotai": "^2.18.1",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "^0.16.10",
|
"katex": "0.16.40",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.3.0",
|
||||||
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
|
"mermaid": "^11.13.0",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"posthog-js": "1.372.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "^3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
|
"react-clear-modal": "^2.0.18",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-drawio": "^1.0.7",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-error-boundary": "^6.1.1",
|
||||||
"react-moveable": "^0.56.0",
|
"react-helmet-async": "^3.0.0",
|
||||||
"react-router-dom": "^6.24.0",
|
"react-i18next": "16.5.8",
|
||||||
"socket.io-client": "^4.7.5",
|
"react-router-dom": "^7.13.1",
|
||||||
"tippy.js": "^6.3.7",
|
"semver": "^7.7.4",
|
||||||
"zod": "^3.23.8"
|
"socket.io-client": "^4.8.3",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/eslint-plugin-query": "^5.47.0",
|
"@eslint/js": "^9.28.0",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.94.4",
|
||||||
|
"@types/blueimp-load-image": "^5.16.6",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/katex": "^0.16.7",
|
"@types/katex": "^0.16.8",
|
||||||
"@types/node": "20.14.9",
|
"@types/node": "22.19.1",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@typescript-eslint/parser": "^7.14.1",
|
"eslint": "^9.28.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint": "^9.5.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.7",
|
"globals": "^15.13.0",
|
||||||
"optics-ts": "^2.4.1",
|
"optics-ts": "^2.4.1",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.5.12",
|
||||||
"postcss-preset-mantine": "^1.15.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.3.1"
|
"typescript-eslint": "^8.57.1",
|
||||||
|
"vite": "8.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 562 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 509 B |
Binary file not shown.
|
After Width: | Height: | Size: 881 B |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "Docmost",
|
||||||
|
"short_name": "Docmost",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#222",
|
||||||
|
"theme_color": "#222",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/favicon-16x16.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/favicon-32x32.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/app-icon-192x192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "180x180 192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/app-icon-512x512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+80
-47
@@ -10,51 +10,46 @@ import Groups from "@/pages/settings/group/groups";
|
|||||||
import GroupInfo from "./pages/settings/group/group-info";
|
import GroupInfo from "./pages/settings/group/group-info";
|
||||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
import Spaces from "@/pages/settings/space/spaces.tsx";
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
|
||||||
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts";
|
|
||||||
import { SOCKET_URL } from "@/features/websocket/types";
|
|
||||||
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
||||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
import SpaceHome from "@/pages/space/space-home.tsx";
|
||||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||||
import Layout from "@/components/layouts/global/layout.tsx";
|
import Layout from "@/components/layouts/global/layout.tsx";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
|
||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
|
import Billing from "@/ee/billing/pages/billing.tsx";
|
||||||
|
import CloudLogin from "@/ee/pages/cloud-login.tsx";
|
||||||
|
import CreateWorkspace from "@/ee/pages/create-workspace.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Security from "@/ee/security/pages/security.tsx";
|
||||||
|
import License from "@/ee/licence/pages/license.tsx";
|
||||||
|
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||||
|
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||||
|
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
|
||||||
|
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||||
|
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||||
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
|
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||||
|
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||||
|
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||||
|
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||||
|
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||||
|
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||||
|
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||||
|
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||||
|
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||||
|
import TemplateList from "@/ee/template/pages/template-list";
|
||||||
|
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||||
|
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||||
|
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||||
|
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [, setSocket] = useAtom(socketAtom);
|
const { t } = useTranslation();
|
||||||
const authToken = useAtomValue(authTokensAtom);
|
useRedirectToCloudSelect();
|
||||||
|
useTrackOrigin();
|
||||||
useEffect(() => {
|
|
||||||
if (!authToken?.accessToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newSocket = io(SOCKET_URL, {
|
|
||||||
transports: ["websocket"],
|
|
||||||
auth: {
|
|
||||||
token: authToken.accessToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
setSocket(newSocket);
|
|
||||||
|
|
||||||
newSocket.on("connect", () => {
|
|
||||||
console.log("ws connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
console.log("ws disconnected");
|
|
||||||
newSocket.disconnect();
|
|
||||||
};
|
|
||||||
}, [authToken?.accessToken]);
|
|
||||||
|
|
||||||
useQuerySubscription();
|
|
||||||
useTreeSocket();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -62,23 +57,51 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
|
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||||
|
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||||
|
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
|
||||||
|
|
||||||
|
{!isCloud() && (
|
||||||
|
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCloud() && (
|
||||||
|
<>
|
||||||
|
<Route path={"/create"} element={<CreateWorkspace />} />
|
||||||
|
<Route path={"/select"} element={<CloudLogin />} />
|
||||||
|
<Route path={"/verify-email"} element={<VerifyEmail />} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Route element={<ShareLayout />}>
|
||||||
|
<Route
|
||||||
|
path={"/share/:shareId/p/:pageSlug"}
|
||||||
|
element={<SharedPage />}
|
||||||
|
/>
|
||||||
|
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
|
||||||
|
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={"/home"} element={<Home />} />
|
<Route path={"/home"} element={<Home />} />
|
||||||
|
<Route path={"/ai"} element={<AiChat />} />
|
||||||
|
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
||||||
|
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||||
|
<Route path={"/favorites"} element={<FavoritesPage />} />
|
||||||
|
<Route path={"/templates"} element={<TemplateList />} />
|
||||||
|
<Route
|
||||||
|
path={"/templates/:templateId"}
|
||||||
|
element={<TemplateEditor />}
|
||||||
|
/>
|
||||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||||
|
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||||
<Route
|
<Route
|
||||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
element={
|
element={<Page />}
|
||||||
<ErrorBoundary
|
|
||||||
fallback={<>Failed to load page. An error occurred.</>}
|
|
||||||
>
|
|
||||||
<Page />
|
|
||||||
</ErrorBoundary>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path={"/settings"}>
|
<Route path={"/settings"}>
|
||||||
@@ -87,11 +110,21 @@ export default function App() {
|
|||||||
path={"account/preferences"}
|
path={"account/preferences"}
|
||||||
element={<AccountPreferences />}
|
element={<AccountPreferences />}
|
||||||
/>
|
/>
|
||||||
|
<Route path={"account/api-keys"} element={<UserApiKeys />} />
|
||||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||||
|
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
|
<Route path={"sharing"} element={<Shares />} />
|
||||||
|
<Route path={"security"} element={<Security />} />
|
||||||
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
|
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||||
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useRef } from "react";
|
||||||
|
import { Menu, Box, Loader } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IconTrash, IconUpload } from "@tabler/icons-react";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
|
interface AvatarUploaderProps {
|
||||||
|
currentImageUrl?: string | null;
|
||||||
|
fallbackName?: string;
|
||||||
|
radius?: string | number;
|
||||||
|
size?: string | number;
|
||||||
|
variant?: string;
|
||||||
|
type: AvatarIconType;
|
||||||
|
onUpload: (file: File) => Promise<void>;
|
||||||
|
onRemove: () => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AvatarUploader({
|
||||||
|
currentImageUrl,
|
||||||
|
fallbackName,
|
||||||
|
radius,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
onUpload,
|
||||||
|
onRemove,
|
||||||
|
isLoading = false,
|
||||||
|
disabled = false,
|
||||||
|
}: AvatarUploaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileInputChange = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 10MB)
|
||||||
|
const maxSizeInBytes = 10 * 1024 * 1024;
|
||||||
|
if (file.size > maxSizeInBytes) {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Image exceeds 10MB limit."),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
// Reset the input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onUpload(file);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to upload image"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the input so the same file can be selected again
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadClick = () => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
} else {
|
||||||
|
console.error("File input ref is null!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ariaLabel = {
|
||||||
|
[AvatarIconType.AVATAR]: t("Change avatar"),
|
||||||
|
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
|
||||||
|
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
|
||||||
|
}[type];
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onRemove();
|
||||||
|
notifications.show({
|
||||||
|
message: t("Image removed successfully"),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to remove image"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
|
||||||
|
<Menu.Target>
|
||||||
|
<Box style={{ position: "relative", display: "inline-block" }}>
|
||||||
|
<CustomAvatar
|
||||||
|
component="button"
|
||||||
|
size={size}
|
||||||
|
avatarUrl={currentImageUrl}
|
||||||
|
name={fallbackName}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
style={{
|
||||||
|
cursor: disabled || isLoading ? "default" : "pointer",
|
||||||
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
radius={radius}
|
||||||
|
variant={variant}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconUpload size={16} />}
|
||||||
|
disabled={isLoading || disabled}
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
>
|
||||||
|
{t("Upload image")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
{currentImageUrl && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
color="red"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isLoading || disabled}
|
||||||
|
>
|
||||||
|
{t("Remove image")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
|
||||||
|
// modified to use the polyfilled clipboard api
|
||||||
|
import React from "react";
|
||||||
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
|
import { useProps } from "@mantine/core";
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
/** Children callback, provides current status and copy function as an argument */
|
||||||
|
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
|
||||||
|
|
||||||
|
/** Value that is copied to the clipboard when the button is clicked */
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
/** Copied status timeout in ms @default `1000` */
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
timeout: 1000,
|
||||||
|
} satisfies Partial<CopyButtonProps>;
|
||||||
|
|
||||||
|
export function CopyButton(props: CopyButtonProps) {
|
||||||
|
const { children, timeout, value, ...others } = useProps(
|
||||||
|
"CopyButton",
|
||||||
|
defaultProps,
|
||||||
|
props,
|
||||||
|
);
|
||||||
|
const clipboard = useClipboard({ timeout });
|
||||||
|
const copy = () => clipboard.copy(value);
|
||||||
|
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyButton.displayName = "@mantine/core/CopyButton";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core";
|
||||||
|
import { CopyButton } from "@/components/common/copy-button";
|
||||||
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface CopyProps {
|
||||||
|
text: string;
|
||||||
|
size?: MantineSize;
|
||||||
|
color?: MantineColor;
|
||||||
|
}
|
||||||
|
export default function CopyTextButton({ text, size }: CopyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopyButton value={text} timeout={2000}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? t("Copied") : t("Copy")}
|
||||||
|
withArrow
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
color={copied ? "teal" : "gray"}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={copy}
|
||||||
|
size={size}
|
||||||
|
aria-label={copied ? t("Copied") : t("Copy")}
|
||||||
|
>
|
||||||
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Divider,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { exportSpace } from "@/features/space/services/space-service";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
id: string;
|
||||||
|
type: "space" | "page";
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportModal({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: ExportModalProps) {
|
||||||
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||||
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
if (type === "page") {
|
||||||
|
await exportPage({
|
||||||
|
pageId: id,
|
||||||
|
format,
|
||||||
|
includeChildren,
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === "space") {
|
||||||
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Export successful"),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: "Export failed:" + err.response?.data.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
console.error("export error", err);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (format: ExportFormat) => {
|
||||||
|
setFormat(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal.Root
|
||||||
|
opened={open}
|
||||||
|
onClose={onClose}
|
||||||
|
size={500}
|
||||||
|
padding="xl"
|
||||||
|
yOffset="10vh"
|
||||||
|
xOffset={0}
|
||||||
|
mah={400}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Header py={0}>
|
||||||
|
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
|
||||||
|
<Modal.CloseButton />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Format")}</Text>
|
||||||
|
</div>
|
||||||
|
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{type === "page" && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Include subpages")}</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeChildren(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
checked={includeChildren}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap" mt="md">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Include attachments")}</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeAttachments(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
checked={includeAttachments}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "space" && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Include attachments")}</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeAttachments(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
checked={includeAttachments}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="center" mt="md">
|
||||||
|
<Button onClick={onClose} variant="default">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportFormatSelection {
|
||||||
|
format: ExportFormat;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
data={[
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
]}
|
||||||
|
defaultValue={format}
|
||||||
|
onChange={onChange}
|
||||||
|
styles={{ wrapper: { maxWidth: 120 } }}
|
||||||
|
comboboxProps={{ width: "120" }}
|
||||||
|
allowDeselect={false}
|
||||||
|
withCheckIcon={false}
|
||||||
|
aria-label={t("Select export format")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Table, Text } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface NoTableResultsProps {
|
||||||
|
colSpan: number;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={colSpan}>
|
||||||
|
<Text fw={500} c="dimmed" ta="center">
|
||||||
|
{text || t("No results found...")}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Button, Group } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface PagePaginationProps {
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Paginate({
|
||||||
|
hasPrevPage,
|
||||||
|
hasNextPage,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
}: PagePaginationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!hasPrevPage && !hasNextPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group mt="md" justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={!hasPrevPage}
|
||||||
|
>
|
||||||
|
{t("Prev")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!hasNextPage}
|
||||||
|
>
|
||||||
|
{t("Next")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,76 +4,108 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ScrollArea,
|
ThemeIcon,
|
||||||
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentChanges({ spaceId }: Props) {
|
export default function RecentChanges({ spaceId }: Props) {
|
||||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
|
||||||
|
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageListSkeleton />;
|
return <PageListSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <Text>Failed to fetch recent pages</Text>;
|
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pages && pages.items.length > 0 ? (
|
return pages.length > 0 ? (
|
||||||
<ScrollArea>
|
<>
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
<Table.ScrollContainer minWidth={500}>
|
||||||
<Table.Tbody>
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
{pages.items.map((page) => (
|
<Table.Tbody>
|
||||||
<Table.Tr key={page.id}>
|
{pages.map((page) => (
|
||||||
<Table.Td>
|
<Table.Tr key={page.id}>
|
||||||
<UnstyledButton
|
|
||||||
component={Link}
|
|
||||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
|
||||||
>
|
|
||||||
<Group wrap="nowrap">
|
|
||||||
{page.icon || <IconFileDescription size={18} />}
|
|
||||||
|
|
||||||
<Text fw={500} size="md" lineClamp={1}>
|
|
||||||
{page.title || "Untitled"}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
</Table.Td>
|
|
||||||
{!spaceId && (
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<UnstyledButton
|
||||||
color="blue"
|
|
||||||
variant="light"
|
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(page?.space.slug)}
|
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
>
|
||||||
{page?.space.name}
|
<Group wrap="nowrap">
|
||||||
</Badge>
|
{page.icon || (
|
||||||
|
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text fw={500} size="md" lineClamp={1}>
|
||||||
|
{page.title || t("Untitled")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
)}
|
{!spaceId && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text c="dimmed" size="xs" fw={500}>
|
<Badge
|
||||||
{formattedDate(page.updatedAt)}
|
color={getInitialsColor(page?.space.name)}
|
||||||
</Text>
|
variant="light"
|
||||||
</Table.Td>
|
component={Link}
|
||||||
</Table.Tr>
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
))}
|
style={{ cursor: "pointer" }}
|
||||||
</Table.Tbody>
|
>
|
||||||
</Table>
|
{page?.space.name}
|
||||||
</ScrollArea>
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
size="xs"
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
|
{formattedDate(page.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
mb="xl"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
loading={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{t("Load more")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text size="md" ta="center">
|
<EmptyState
|
||||||
No pages yet
|
icon={IconFiles}
|
||||||
</Text>
|
title={t("No pages yet")}
|
||||||
|
description={t("Pages you create will show up here.")}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { TextInput, Group } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface SearchInputProps {
|
||||||
|
placeholder?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
debounceDelay?: number;
|
||||||
|
onSearch: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchInput({
|
||||||
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
|
debounceDelay = 500,
|
||||||
|
onSearch,
|
||||||
|
}: SearchInputProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [debouncedValue] = useDebouncedValue(value, debounceDelay);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSearch(debouncedValue);
|
||||||
|
}, [debouncedValue, onSearch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group mb="sm">
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
placeholder={placeholder || t("Search...")}
|
||||||
|
aria-label={ariaLabel || placeholder || t("Search")}
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import React from "react";
|
||||||
|
import { IUser } from '@/features/user/types/user.types.ts';
|
||||||
|
|
||||||
|
interface UserInfoProps {
|
||||||
|
user: Partial<IUser>;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
export function UserInfo({ user, size }: UserInfoProps) {
|
||||||
|
return (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
|
||||||
|
<div>
|
||||||
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
|
{user?.name}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{user?.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AirtableIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 256 215"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#ffbf00"
|
||||||
|
d="M114.259 2.701 18.86 42.176c-5.305 2.195-5.25 9.73.089 11.847l95.797 37.989a35.544 35.544 0 0 0 26.208 0l95.799-37.99c5.337-2.115 5.393-9.65.086-11.846L141.442 2.7a35.549 35.549 0 0 0-27.183 0"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#26b5f8"
|
||||||
|
d="M136.35 112.757v94.902c0 4.514 4.55 7.605 8.746 5.942l106.748-41.435a6.39 6.39 0 0 0 4.035-5.941V71.322c0-4.514-4.551-7.604-8.747-5.941l-106.748 41.434a6.392 6.392 0 0 0-4.035 5.942"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#ed3049"
|
||||||
|
d="m111.423 117.654-31.68 15.296-3.217 1.555L9.65 166.548C5.411 168.593 0 165.504 0 160.795V71.72c0-1.704.874-3.175 2.046-4.283a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillOpacity={0.25}
|
||||||
|
d="m111.423 117.654-31.68 15.296L2.045 67.438a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfluenceIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FigmaIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<g fill="none" fillRule="evenodd" transform="translate(4)">
|
||||||
|
<circle cx={12} cy={12} r={4} fill="#19bcfe" />
|
||||||
|
<path fill="#09cf83" d="M4 24a4 4 0 0 0 4-4v-4H4a4 4 0 1 0 0 8z" />
|
||||||
|
<path fill="#a259ff" d="M4 16h4V8H4a4 4 0 1 0 0 8z" />
|
||||||
|
<path fill="#f24e1e" d="M4 8h4V0H4a4 4 0 1 0 0 8z" />
|
||||||
|
<path fill="#ff7262" d="M12 8H8V0h4a4 4 0 1 1 0 8z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FramerIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M4 0h16v8h-8zm0 8h8l8 8H4zm0 8h8v8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleDriveIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 87.3 78"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da" />
|
||||||
|
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47" />
|
||||||
|
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
|
||||||
|
fill="#ea4335" />
|
||||||
|
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d" />
|
||||||
|
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc" />
|
||||||
|
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||||
|
fill="#ffba00" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
preserveAspectRatio="xMidYMid"
|
||||||
|
viewBox="0 0 256 262"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EB4335"
|
||||||
|
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleSheetsIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path fill="#43a047" d="M37,45H11c-1.657,0-3-1.343-3-3V6c0-1.657,1.343-3,3-3h19l10,10v29C40,43.657,38.657,45,37,45z"/>
|
||||||
|
<path fill="#c8e6c9" d="M40 13L30 13 30 3z"/>
|
||||||
|
<path fill="#2e7d32" d="M30 13L40 23 40 13z"/>
|
||||||
|
<path
|
||||||
|
fill="#e8f5e9"
|
||||||
|
d="M31,23H17h-2v2v2v2v2v2v2v2h18v-2v-2v-2v-2v-2v-2v-2H31z M17,25h4v2h-4V25z M17,29h4v2h-4V29z M17,33h4v2h-4V33z M31,35h-8v-2h8V35z M31,31h-8v-2h8V31z M31,27h-8v-2h8V27z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number | string;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M7.5 3v18" />
|
||||||
|
<path d="M12 3v18" />
|
||||||
|
<path d="M16.5 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number | string;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M6.6 3v18" />
|
||||||
|
<path d="M10.2 3v18" />
|
||||||
|
<path d="M13.8 3v18" />
|
||||||
|
<path d="M17.4 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconDrawio({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#F08705"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconDrawio;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconExcalidraw({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="#6965DB"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M23.943 19.806a.196.196 0 00-.168-.034c-1.26-1.855-2.873-3.61-4.419-5.315l-.252-.284c-.001-.073-.067-.12-.134-.15l-.084-.084c-.05-.1-.169-.167-.286-.1-.47.234-.907.585-1.327.919-.554.434-1.109.87-1.63 1.354a5.058 5.058 0 00-.588.618c-.084.117-.017.217.084.267-.37.368-.74.736-1.109 1.12a.19.19 0 00-.05.134c0 .05.033.1.067.117l.655.502v.016c.924.92 2.554 2.173 4.285 3.527.251.201.52.402.773.602.117.134.234.285.335.418.05.066.169.084.236.033.033.034.084.067.118.1a.24.24 0 00.1.034.153.153 0 00.135-.066.237.237 0 00.033-.1c.017 0 .017.016.034.016a.192.192 0 00.134-.05l3.058-3.327c.12-.116.014-.267 0-.267zm-7.628-.134l-1.546-1.17-.15-.1c-.035-.017-.068-.05-.102-.067l-.117-.1c.66-.66 1.33-1.308 2-1.956-.488.484-1.463 1.906-1.261 2.373.002 0 .018.042.067.084l1.11.936zm4.1 3.126l-1.277-.97a26.906 26.906 0 00-1.58-1.504c.69.518 1.277.97 1.361 1.053.673.585.638.485 1.093.87l.554.4c-.074.103-.151.148-.151.151zm.336.25l-.034-.016a.913.913 0 00.152-.117zM.587 3.476c.034.217.085.435.118.636.201 1.103.403 2.106.772 2.858l.152.568c.05.217.134.485.219.552a66.769 66.769 0 003.578 2.942.177.177 0 00.219 0s0 .016.016.016a.153.153 0 00.118.05.191.191 0 00.134-.05c1.798-1.989 3.142-3.627 4.1-4.998.068-.066.084-.167.084-.25.067-.067.118-.151.185-.201.067-.067.067-.184 0-.235l-.017-.016c0-.033-.017-.084-.05-.1-.42-.401-.722-.685-1.042-.986a93.555 93.555 0 01-2.352-2.273c-.017-.017-.034-.034-.067-.034-.336-.117-1.025-.234-1.882-.385-1.277-.216-3.008-.517-4.57-.986 0 0-.101 0-.118.017l-.05.05C.05.714.022.707 0 .718c.017.1.017.167.05.284 0 .033.068.301.068.334zm7.191 4.78l-.033.034a.036.036 0 01.033-.034zM6.553 2.238c.101.1.521.502.622.585-.437-.2-1.529-.702-2.034-.869.505.1 1.194.201 1.412.284zM.79 1.403c.252.434.454 1.939.655 3.41-.118-.469-.201-.936-.302-1.372C.992 2.673.84 1.988.638 1.386c.124 0 .152.021.152.017zm-.286-.369c0-.016 0-.033-.017-.033.085 0 .135.017.202.05 0 .006-.145-.017-.185-.017zm23.17-.217c.017-.066-.336-.367-.219-.384.253-.017.253-.401 0-.401-.335.017-.688.1-1.008.15-.587.117-1.192.234-1.78.367a79.696 79.696 0 00-3.949.937c-.403.117-.857.2-1.243.401-.135.067-.118.2-.05.284-.034.017-.051.017-.085.034-.117.017-.218.034-.335.05-.102.017-.152.1-.135.2 0 .017.017.05.017.067-.706.936-1.496 1.923-2.353 2.976-.84.969-1.73 1.989-2.62 3.042-2.84 3.31-6.05 7.07-9.594 10.38a.161.161 0 000 .234c.016.016.033.033.05.033-.05.05-.101.085-.152.134-.033.034-.05.067-.05.1a.364.364 0 00-.067.084c-.067.067-.067.184.017.234.067.066.185.066.235-.017.017-.017.017-.033.033-.033a.265.265 0 01.37 0c.202.217.404.435.588.618l-.42-.35c-.067-.067-.184-.05-.234.016-.068.066-.051.184.016.234l4.469 3.727c.034.034.067.034.118.034a.15.15 0 00.117-.05l.101-.1c.017.016.05.016.067.016.05 0 .084-.016.118-.05 6.049-6.05 10.922-10.614 16.5-14.693.05-.033.067-.1.067-.15.067 0 .118-.05.15-.117 1.026-3.125 1.228-5.9 1.295-7.27 0-.059.016-.038.016-.068.017-.033.017-.05.017-.05a.978.978 0 00-.067-.619zm-10.82 4.915c.268-.301.537-.619.806-.903-1.73 2.273-4.603 5.766-8.67 9.929 2.773-3.059 5.562-6.218 7.864-9.026zM5.14 23.466c-.016-.017-.016-.017 0-.017zm2.504-2.156c.135-.15.27-.284.42-.434 0 0 0 .016.017.016-.224.198-.433.418-.437.418zm.69-.668c.099-.1.14-.173.284-.318.992-1.02 2.017-2.04 3.059-3.076l.016-.016c.252-.2.555-.418.824-.619a228.063 228.063 0 00-4.184 4.029zM14.852 3.91c-.554.719-1.176 1.671-1.697 2.423-1.646 2.374-6.94 8.174-7.057 8.274a1189.647 1189.647 0 01-4.839 4.597l-.1.1c-.085-.1-.085-.25.016-.334C8.652 11.966 13.19 6.133 15.021 3.576c-.05.116-.084.216-.168.334zm2.906 3.427c-.671-.386-.99-.987-.806-1.572l.05-.2a.775.775 0 01.085-.167 1.9 1.9 0 01.756-.703c.016 0 .033 0 .05-.016-.017-.034-.017-.084-.017-.134.017-.1.085-.167.202-.167.202 0 .824.184 1.059.384.067.05.134.117.202.184.084.1.218.268.285.401.034.017.067.184.118.268.033.134.067.284.05.418-.017.016 0 .116-.017.116a1.605 1.605 0 01-.218.619c-.03.03.006.012-.05.067a1.22 1.22 0 01-.32.334 1.49 1.49 0 01-1.26.234 2.191 2.191 0 00-.169-.066zm4.37 1.403c0 .017-.017.05 0 .067-.034 0-.05.017-.085.034a109.886 109.886 0 00-3.915 3.025c1.11-.986 2.218-1.989 3.378-2.975.336-.301.571-.686.638-1.12l.168-1.003v-.033c.085-.201.404-.118.353.1-.004-.001-.173.795-.537 1.905z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconExcalidraw;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconMermaid({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="#FF3670"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M23.99 2.115A12.223 12.223 0 0012 10.149 12.223 12.223 0 00.01 2.115a12.23 12.23 0 005.32 10.604 6.562 6.562 0 012.845 5.423v3.754h7.65v-3.754a6.561 6.561 0 012.844-5.423 12.223 12.223 0 005.32-10.604z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IconMermaid;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ActionIcon, rem } from "@mantine/core";
|
import { ThemeIcon } from "@mantine/core";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconUsersGroup } from "@tabler/icons-react";
|
import { IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
export function IconGroupCircle() {
|
export function IconGroupCircle() {
|
||||||
return (
|
return (
|
||||||
<ActionIcon variant="light" size="lg" color="gray" radius="xl">
|
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
|
||||||
<IconUsersGroup stroke={1.5} />
|
<IconUsersGroup stroke={1.5} />
|
||||||
</ActionIcon>
|
</ThemeIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { AirtableIcon } from "./airtable-icon.tsx";
|
||||||
|
export { FigmaIcon } from "./figma-icon.tsx";
|
||||||
|
export { TypeformIcon } from "./typeform-icon.tsx";
|
||||||
|
export { VimeoIcon } from "./vimeo-icon.tsx";
|
||||||
|
export { MiroIcon } from "./miro-icon.tsx";
|
||||||
|
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
|
||||||
|
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
|
||||||
|
export { FramerIcon } from "./framer-icon.tsx";
|
||||||
|
export { LoomIcon } from "./loom-icon.tsx";
|
||||||
|
export { YoutubeIcon } from "./youtube-icon.tsx";
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoomIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#625DF5"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M24 10.665h-7.018l6.078-3.509-1.335-2.312-6.078 3.509 3.508-6.077L16.843.94l-3.508 6.077V0h-2.67v7.018L7.156.94 4.844 2.275l3.509 6.077-6.078-3.508L.94 7.156l6.078 3.509H0v2.67h7.017L.94 16.844l1.335 2.313 6.077-3.508-3.509 6.077 2.312 1.335 3.509-6.078V24h2.67v-7.017l3.508 6.077 2.312-1.335-3.509-6.078 6.078 3.509 1.335-2.313-6.077-3.508h7.017v-2.67H24zm-12 4.966a3.645 3.645 0 1 1 0-7.29 3.645 3.645 0 0 1 0 7.29z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiroIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenIdIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M14.54.889l-3.63 1.773v18.17c-4.15-.52-7.27-2.78-7.27-5.5 0-2.58 2.8-4.75 6.63-5.41v-2.31C4.42 8.322 0 11.502 0 15.332c0 3.96 4.74 7.24 10.91 7.78l3.63-1.71V.888m.64 6.724v2.31c1.43.25 2.71.7 3.76 1.31l-1.97 1.11 7.03 1.53-.5-5.21-1.87 1.06c-1.74-1.06-3.96-1.81-6.45-2.11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypeformIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15.502 13.035c-.5 0-.756-.411-.756-.917 0-.505.252-.894.756-.894.513 0 .756.407.756.894-.004.515-.261.917-.756.917Zm-4.888-1.81c.292 0 .414.17.414.317 0 .357-.365.514-1.126.536 0-.442.253-.854.712-.854Zm-3.241 1.81c-.473 0-.67-.384-.67-.917 0-.527.202-.894.67-.894.477 0 .702.38.702.894 0 .537-.234.917-.702.917Zm-3.997-2.334h-.738l1.224 2.808c-.234.519-.36.648-.522.648-.171 0-.333-.138-.45-.259l-.324.43c.22.232.522.366.832.366.387 0 .685-.224.856-.626l1.413-3.371h-.725l-.738 2.012-.828-2.008Zm19.553.523c.36 0 .432.246.432.823v1.516H24v-1.914c0-.689-.473-.988-.91-.988-.386 0-.742.241-.94.688a.901.901 0 0 0-.891-.688c-.365 0-.73.232-.927.666v-.626h-.64v2.857h.64v-1.22c0-.617.324-1.114.765-1.114.36 0 .427.246.427.823v1.516h.64l-.005-1.225c0-.617.329-1.114.77-1.114Zm-5.1-.523h-.324v2.857h.639v-1.095c0-.693.306-1.163.76-1.163.118 0 .217.005.325.05l.099-.676c-.081-.009-.153-.018-.225-.018-.45 0-.774.309-.964.707V10.7h-.31Zm-2.327-.045c-.846 0-1.418.644-1.418 1.458 0 .845.58 1.475 1.418 1.475.85 0 1.431-.648 1.431-1.475-.004-.818-.594-1.458-1.431-1.458Zm-4.852 2.38c-.333 0-.581-.17-.685-.515.847-.036 1.675-.242 1.675-.988 0-.43-.423-.872-1.03-.872-.82 0-1.374.666-1.374 1.457 0 .828.545 1.476 1.36 1.476.567 0 .927-.228 1.21-.559l-.31-.42c-.329.335-.531.42-.846.42Zm-3.151-2.38c-.324 0-.648.188-.774.483v-.438h-.64v3.98h.64v-1.422c.135.205.445.34.72.34.85 0 1.3-.631 1.3-1.48-.004-.841-.445-1.463-1.246-1.463Zm-4.483-1.1H0v.622h1.18v3.38h.67v-3.38h1.166v-.622Zm9.502 1.145h-.383v.572h.383v2.285h.639v-2.285h.621v-.572h-.621v-.447c0-.286.117-.385.382-.385.1 0 .19.027.311.068l.144-.537c-.117-.067-.351-.094-.504-.094-.612 0-.972.367-.972 1.002v.393Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VimeoIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#1AB7EA"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { rem } from '@mantine/core';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YoutubeIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#FF0000"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,19 @@
|
|||||||
padding-right: var(--mantine-spacing-md);
|
padding-right: var(--mantine-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
display: block;
|
display: block;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -16,6 +29,9 @@
|
|||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
font-size: var(--mantine-font-size-sm);
|
font-size: var(--mantine-font-size-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
@mixin hover {
|
@mixin hover {
|
||||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
|
|||||||
@@ -1,31 +1,62 @@
|
|||||||
import { Group, Text } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
import classes from "./app-header.module.css";
|
import classes from "./app-header.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { IconSparkles } from "@tabler/icons-react";
|
||||||
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
SearchControl,
|
||||||
|
SearchMobileControl,
|
||||||
|
} from "@/features/search/components/search-control.tsx";
|
||||||
|
import {
|
||||||
|
searchSpotlight,
|
||||||
|
shareSearchSpotlight,
|
||||||
|
} from "@/features/search/constants.ts";
|
||||||
|
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
const links = [
|
||||||
|
{ link: APP_ROUTE.HOME, label: "Home" },
|
||||||
|
];
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
|
const { isTrial, trialDaysLeft } = useTrial();
|
||||||
|
const location = useLocation();
|
||||||
|
const toggleAside = useToggleAside();
|
||||||
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
|
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
|
|
||||||
const items = links.map((link) => (
|
const items = links.map((link) => (
|
||||||
<Link key={link.label} to={link.link} className={classes.link}>
|
<Link key={link.label} to={link.link} className={classes.link}>
|
||||||
{link.label}
|
{t(link.label)}
|
||||||
</Link>
|
</Link>
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -33,40 +64,117 @@ export function AppHeader() {
|
|||||||
<>
|
<>
|
||||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{!isHomeRoute && (
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
<>
|
<SidebarToggle
|
||||||
<SidebarToggle
|
aria-label={t("Sidebar toggle")}
|
||||||
aria-label="sidebar toggle"
|
opened={mobileOpened}
|
||||||
opened={mobileOpened}
|
onClick={toggleMobile}
|
||||||
onClick={toggleMobile}
|
hiddenFrom="sm"
|
||||||
hiddenFrom="sm"
|
size="sm"
|
||||||
size="sm"
|
/>
|
||||||
/>
|
</Tooltip>
|
||||||
|
|
||||||
<SidebarToggle
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
aria-label="sidebar toggle"
|
<SidebarToggle
|
||||||
opened={desktopOpened}
|
aria-label={t("Sidebar toggle")}
|
||||||
onClick={toggleDesktop}
|
opened={desktopOpened}
|
||||||
visibleFrom="sm"
|
onClick={toggleDesktop}
|
||||||
size="sm"
|
visibleFrom="sm"
|
||||||
/>
|
size="sm"
|
||||||
</>
|
/>
|
||||||
)}
|
</Tooltip>
|
||||||
|
|
||||||
<Text
|
<Link to="/home" className={classes.brand} aria-label="Docmost">
|
||||||
size="lg"
|
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||||
fw={600}
|
<img
|
||||||
style={{ cursor: "pointer", userSelect: "none" }}
|
src="/icons/favicon-32x32.png"
|
||||||
>
|
alt="Docmost"
|
||||||
Docmost
|
width={22}
|
||||||
</Text>
|
height={22}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
fw={600}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
visibleFrom="sm"
|
||||||
|
>
|
||||||
|
Docmost
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
|
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
|
||||||
{items}
|
{items}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group px={"xl"}>
|
<div>
|
||||||
|
<Group visibleFrom="sm">
|
||||||
|
<SearchControl onClick={searchSpotlight.open} />
|
||||||
|
</Group>
|
||||||
|
<Group hiddenFrom="sm">
|
||||||
|
<SearchMobileControl onSearch={searchSpotlight.open} />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Group px={"xl"} wrap="nowrap">
|
||||||
|
{aiChatEnabled && (
|
||||||
|
<>
|
||||||
|
<UnstyledButton
|
||||||
|
component={Link}
|
||||||
|
to="/ai"
|
||||||
|
className={classes.link}
|
||||||
|
visibleFrom="sm"
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPageRoute) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleAside("chat");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("AI Chat")}
|
||||||
|
</UnstyledButton>
|
||||||
|
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
to="/ai"
|
||||||
|
variant="subtle"
|
||||||
|
color="dark"
|
||||||
|
size="sm"
|
||||||
|
hiddenFrom="sm"
|
||||||
|
aria-label={t("AI Chat")}
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPageRoute) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleAside("chat");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconSparkles size={20} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<NotificationPopover />
|
||||||
|
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
|
||||||
|
visibleFrom="xs"
|
||||||
|
>
|
||||||
|
{trialDaysLeft === 1
|
||||||
|
? "1 day left"
|
||||||
|
: `${trialDaysLeft} days left`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<TopMenu />
|
<TopMenu />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -14,3 +14,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resizeHandle {
|
||||||
|
width: 3px;
|
||||||
|
cursor: col-resize;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
&:hover, &:active {
|
||||||
|
width: 5px;
|
||||||
|
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,40 +1,66 @@
|
|||||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||||
import CommentList from "@/features/comment/components/comment-list.tsx";
|
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
||||||
|
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||||
|
|
||||||
export default function Aside() {
|
export default function Aside() {
|
||||||
const [{ tab }] = useAtom(asideStateAtom);
|
const [{ tab }] = useAtom(asideStateAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
|
|
||||||
let title: string;
|
let title: string;
|
||||||
let component: ReactNode;
|
let component: ReactNode;
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "comments":
|
case "comments":
|
||||||
component = <CommentList />;
|
component = <CommentListWithTabs />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
|
case "toc":
|
||||||
|
component = <TableOfContents editor={pageEditor} />;
|
||||||
|
title = "Table of contents";
|
||||||
|
break;
|
||||||
|
case "chat":
|
||||||
|
component = <AsideChatPanel />;
|
||||||
|
title = "AI Chat";
|
||||||
|
break;
|
||||||
|
case "details":
|
||||||
|
component = <PageDetailsAside />;
|
||||||
|
title = "Details";
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
component = null;
|
component = null;
|
||||||
title = null;
|
title = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p="md">
|
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
{component && (
|
{component && (
|
||||||
<>
|
<>
|
||||||
<Text mb="md" fw={500}>
|
{tab !== "chat" && (
|
||||||
{title}
|
<Text mb="md" fw={500}>
|
||||||
</Text>
|
{t(title)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScrollArea
|
{tab === "comments" || tab === "chat" ? (
|
||||||
style={{ height: "85vh" }}
|
component
|
||||||
scrollbarSize={5}
|
) : (
|
||||||
type="scroll"
|
<ScrollArea
|
||||||
>
|
style={{ height: "85vh" }}
|
||||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
scrollbarSize={5}
|
||||||
</ScrollArea>
|
type="scroll"
|
||||||
|
>
|
||||||
|
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,46 +1,96 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
asideStateAtom,
|
asideStateAtom,
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
|
sidebarWidthAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
|
import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
|
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||||
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
|
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useTrialEndAction();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const sidebarRef = useRef(null);
|
||||||
|
|
||||||
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
|
mouseDownEvent.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopResizing = React.useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resize = React.useCallback(
|
||||||
|
(mouseMoveEvent) => {
|
||||||
|
if (isResizing) {
|
||||||
|
const newWidth =
|
||||||
|
mouseMoveEvent.clientX -
|
||||||
|
sidebarRef.current.getBoundingClientRect().left;
|
||||||
|
if (newWidth < 220) {
|
||||||
|
setSidebarWidth(220);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newWidth > 600) {
|
||||||
|
setSidebarWidth(600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isResizing],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
//https://codesandbox.io/p/sandbox/kz9de
|
||||||
|
window.addEventListener("mousemove", resize);
|
||||||
|
window.addEventListener("mouseup", stopResizing);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", resize);
|
||||||
|
window.removeEventListener("mouseup", stopResizing);
|
||||||
|
};
|
||||||
|
}, [resize, stopResizing]);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isAiRoute = location.pathname.startsWith("/ai");
|
||||||
const isPageRoute = location.pathname.includes("/p/");
|
const isPageRoute = location.pathname.includes("/p/");
|
||||||
|
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={
|
navbar={{
|
||||||
!isHomeRoute && {
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
width: 300,
|
breakpoint: "sm",
|
||||||
breakpoint: "sm",
|
collapsed: {
|
||||||
collapsed: {
|
mobile: !mobileOpened,
|
||||||
mobile: !mobileOpened,
|
desktop: !desktopOpened,
|
||||||
desktop: !desktopOpened,
|
},
|
||||||
},
|
}}
|
||||||
}
|
|
||||||
}
|
|
||||||
aside={
|
aside={
|
||||||
isPageRoute && {
|
isPageRoute && {
|
||||||
width: 350,
|
width: 350,
|
||||||
@@ -53,22 +103,55 @@ export default function GlobalAppShell({
|
|||||||
<AppShell.Header px="md" className={classes.header}>
|
<AppShell.Header px="md" className={classes.header}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
{!isHomeRoute && (
|
<AppShell.Navbar
|
||||||
<AppShell.Navbar className={classes.navbar} withBorder={false}>
|
className={classes.navbar}
|
||||||
{isSpaceRoute && <SpaceSidebar />}
|
withBorder={false}
|
||||||
{isSettingsRoute && <SettingsSidebar />}
|
ref={sidebarRef}
|
||||||
</AppShell.Navbar>
|
aria-label={
|
||||||
)}
|
isSpaceRoute
|
||||||
<AppShell.Main>
|
? t("Space navigation")
|
||||||
|
: isSettingsRoute
|
||||||
|
? t("Settings navigation")
|
||||||
|
: isAiRoute
|
||||||
|
? t("AI navigation")
|
||||||
|
: t("Main navigation")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSpaceRoute && (
|
||||||
|
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||||
|
)}
|
||||||
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
|
{isSettingsRoute && <SettingsSidebar />}
|
||||||
|
{isAiRoute && <AiChatSidebar />}
|
||||||
|
{showGlobalSidebar && <GlobalSidebar />}
|
||||||
|
</AppShell.Navbar>
|
||||||
|
<AppShell.Main id="main-content">
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={800}>{children}</Container>
|
<Container size={900} pb={80}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
{isPageRoute && (
|
{isPageRoute && (
|
||||||
<AppShell.Aside className={classes.aside} p="md" withBorder={false}>
|
<AppShell.Aside
|
||||||
|
className={classes.aside}
|
||||||
|
p="md"
|
||||||
|
withBorder={false}
|
||||||
|
aria-label={
|
||||||
|
asideTab === "comments"
|
||||||
|
? t("Comments")
|
||||||
|
: asideTab === "toc"
|
||||||
|
? t("Table of contents")
|
||||||
|
: asideTab === "chat"
|
||||||
|
? t("AI Chat")
|
||||||
|
: asideTab === "details"
|
||||||
|
? t("Details")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Aside />
|
<Aside />
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
.navbar {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding-bottom: var(--mantine-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
padding-left: var(--mantine-spacing-xs);
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active] {
|
||||||
|
&,
|
||||||
|
& :hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||||
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkIcon {
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
margin-right: var(--mantine-spacing-sm);
|
||||||
|
width: rem(16px);
|
||||||
|
height: rem(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomSection {
|
||||||
|
padding-top: var(--mantine-spacing-xs);
|
||||||
|
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.spaceItem {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--mantine-spacing-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
padding-left: var(--mantine-spacing-xs);
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconClock,
|
||||||
|
IconStar,
|
||||||
|
IconLayoutGrid,
|
||||||
|
IconSettings,
|
||||||
|
IconUserPlus,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import classes from "./global-sidebar.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar";
|
||||||
|
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||||
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||||
|
|
||||||
|
const mainNavItems = [
|
||||||
|
{ label: "Home", icon: IconHome, path: "/home" },
|
||||||
|
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||||
|
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function GlobalSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const [active, setActive] = useState(location.pathname);
|
||||||
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
||||||
|
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
const sortedFavoriteSpaces = [...favoriteSpaces]
|
||||||
|
.filter((fav) => fav.space)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" });
|
||||||
|
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
const [inviteOpened, { open: openInvite, close: closeInvite }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(location.pathname);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handleNavClick = () => {
|
||||||
|
if (mobileSidebarOpened) {
|
||||||
|
toggleMobileSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.navbar}>
|
||||||
|
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||||
|
<div className={classes.section}>
|
||||||
|
{mainNavItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
className={classes.link}
|
||||||
|
data-active={active === item.path || undefined}
|
||||||
|
to={item.path}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider my="xs" />
|
||||||
|
<div className={classes.section}>
|
||||||
|
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||||
|
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||||
|
{t("Favorite spaces appear here")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{sortedFavoriteSpaces.slice(0, 10).map((fav) => (
|
||||||
|
<Link
|
||||||
|
key={fav.id}
|
||||||
|
className={classes.spaceItem}
|
||||||
|
to={getSpaceUrl(fav.space!.slug)}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
<CustomAvatar
|
||||||
|
name={fav.space!.name}
|
||||||
|
avatarUrl={fav.space!.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
|
color="initials"
|
||||||
|
variant="filled"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
|
{fav.space!.name}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{sortedFavoriteSpaces.length > 10 && (
|
||||||
|
<Link
|
||||||
|
className={classes.spaceItem}
|
||||||
|
to="/spaces"
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("View all")}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className={classes.bottomSection}>
|
||||||
|
<UnstyledButton
|
||||||
|
className={classes.link}
|
||||||
|
onClick={openInvite}
|
||||||
|
>
|
||||||
|
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t("Invite People")}</span>
|
||||||
|
</UnstyledButton>
|
||||||
|
<Link
|
||||||
|
className={classes.link}
|
||||||
|
data-active={active.startsWith("/settings") || undefined}
|
||||||
|
to="/settings/account/profile"
|
||||||
|
onClick={handleNavClick}
|
||||||
|
>
|
||||||
|
<IconSettings className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t("Settings")}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
size="550"
|
||||||
|
opened={inviteOpened}
|
||||||
|
onClose={closeInvite}
|
||||||
|
title={t("Invite new members")}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Divider size="xs" mb="xs" />
|
||||||
|
<ScrollArea h="80%">
|
||||||
|
<WorkspaceInviteForm onClose={closeInvite} />
|
||||||
|
</ScrollArea>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
|||||||
|
|
||||||
export const desktopAsideAtom = atom<boolean>(false);
|
export const desktopAsideAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
||||||
type AsideStateType = {
|
type AsideStateType = {
|
||||||
tab: string;
|
tab: string;
|
||||||
isAsideOpen: boolean;
|
isAsideOpen: boolean;
|
||||||
@@ -19,3 +20,5 @@ export const asideStateAtom = atom<AsideStateType>({
|
|||||||
tab: "",
|
tab: "",
|
||||||
isAsideOpen: false,
|
isAsideOpen: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
|
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
|
import React from "react";
|
||||||
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const { spaceSlug } = useParams();
|
||||||
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
|
{isCloud() && <PosthogUser />}
|
||||||
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBrightnessFilled,
|
||||||
IconBrush,
|
IconBrush,
|
||||||
|
IconCheck,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
|
IconDeviceDesktop,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
|
IconMoon,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconSun,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -13,10 +24,14 @@ import { Link } from "react-router-dom";
|
|||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
export default function TopMenu() {
|
export default function TopMenu() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
const user = currentUser?.user;
|
const user = currentUser?.user;
|
||||||
const workspace = currentUser?.workspace;
|
const workspace = currentUser?.workspace;
|
||||||
@@ -31,27 +46,28 @@ export default function TopMenu() {
|
|||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<Group gap={7} wrap={"nowrap"}>
|
<Group gap={7} wrap={"nowrap"}>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
avatarUrl={workspace.logo}
|
avatarUrl={workspace?.logo}
|
||||||
name={workspace.name}
|
name={workspace?.name}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
type={AvatarIconType.WORKSPACE_ICON}
|
||||||
/>
|
/>
|
||||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||||
{workspace.name}
|
{workspace?.name}
|
||||||
</Text>
|
</Text>
|
||||||
<IconChevronDown size={16} />
|
<IconChevronDown size={16} />
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Label>Workspace</Menu.Label>
|
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={Link}
|
component={Link}
|
||||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||||
leftSection={<IconSettings size={16} />}
|
leftSection={<IconSettings size={16} />}
|
||||||
>
|
>
|
||||||
Workspace settings
|
{t("Workspace settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -59,12 +75,12 @@ export default function TopMenu() {
|
|||||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||||
leftSection={<IconUsers size={16} />}
|
leftSection={<IconUsers size={16} />}
|
||||||
>
|
>
|
||||||
Manage members
|
{t("Manage members")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Label>Account</Menu.Label>
|
<Menu.Label>{t("Account")}</Menu.Label>
|
||||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||||
<Group wrap={"nowrap"}>
|
<Group wrap={"nowrap"}>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
@@ -73,11 +89,11 @@ export default function TopMenu() {
|
|||||||
name={user.name}
|
name={user.name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div style={{ width: 190 }}>
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
{user.name}
|
{user.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed" truncate="end">
|
||||||
{user.email}
|
{user.email}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +104,7 @@ export default function TopMenu() {
|
|||||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||||
leftSection={<IconUserCircle size={16} />}
|
leftSection={<IconUserCircle size={16} />}
|
||||||
>
|
>
|
||||||
My profile
|
{t("My profile")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -96,13 +112,51 @@ export default function TopMenu() {
|
|||||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||||
leftSection={<IconBrush size={16} />}
|
leftSection={<IconBrush size={16} />}
|
||||||
>
|
>
|
||||||
My preferences
|
{t("My preferences")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Sub>
|
||||||
|
<Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||||
|
{t("Theme")}
|
||||||
|
</Menu.Sub.Item>
|
||||||
|
</Menu.Sub.Target>
|
||||||
|
|
||||||
|
<Menu.Sub.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("light")}
|
||||||
|
leftSection={<IconSun size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Light")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("dark")}
|
||||||
|
leftSection={<IconMoon size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Dark")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("auto")}
|
||||||
|
leftSection={<IconDeviceDesktop size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("System settings")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Sub.Dropdown>
|
||||||
|
</Menu.Sub>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||||
Logout
|
{t("Logout")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useAppVersion } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import classes from "@/components/settings/settings.module.css";
|
||||||
|
import { Indicator, Text, Tooltip } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import semverGt from "semver/functions/gt";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function AppVersion() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: appVersion } = useAppVersion(!isCloud());
|
||||||
|
let hasUpdate = false;
|
||||||
|
try {
|
||||||
|
hasUpdate =
|
||||||
|
appVersion &&
|
||||||
|
parseFloat(appVersion.latestVersion) > 0 &&
|
||||||
|
semverGt(appVersion.latestVersion, appVersion.currentVersion);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.text}>
|
||||||
|
<Tooltip
|
||||||
|
label={t("{{latestVersion}} is available", {
|
||||||
|
latestVersion: `v${appVersion?.latestVersion}`,
|
||||||
|
})}
|
||||||
|
disabled={!hasUpdate}
|
||||||
|
>
|
||||||
|
<Indicator
|
||||||
|
label={t("New update")}
|
||||||
|
color="gray"
|
||||||
|
inline
|
||||||
|
size={16}
|
||||||
|
position="middle-end"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
disabled={!hasUpdate}
|
||||||
|
onClick={() => {
|
||||||
|
window.open(
|
||||||
|
"https://github.com/docmost/docmost/releases",
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component="a"
|
||||||
|
mr={45}
|
||||||
|
href="https://github.com/docmost/docmost/releases"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{appVersion?.currentVersion && <>v{appVersion?.currentVersion}</>}
|
||||||
|
</Text>
|
||||||
|
</Indicator>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { atom, WritableAtom } from "jotai";
|
||||||
|
|
||||||
|
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
|
||||||
|
null,
|
||||||
|
(get, set, newValue) => {
|
||||||
|
if (get(settingsOriginAtom) !== newValue) {
|
||||||
|
set(settingsOriginAtom, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
getBilling,
|
||||||
|
getBillingPlans,
|
||||||
|
} from "@/ee/billing/services/billing-service.ts";
|
||||||
|
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||||
|
import { getGroups } from "@/features/group/services/group-service.ts";
|
||||||
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
|
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
|
||||||
|
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
||||||
|
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
||||||
|
import { getShares } from "@/features/share/services/share-service.ts";
|
||||||
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||||
|
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||||
|
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||||
|
|
||||||
|
export const prefetchWorkspaceMembers = () => {
|
||||||
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["workspaceMembers", params],
|
||||||
|
queryFn: () => getWorkspaceMembers(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchSpaces = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["spaces", {}],
|
||||||
|
queryFn: () => getSpaces({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchGroups = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["groups", {}],
|
||||||
|
queryFn: () => getGroups({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchBilling = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["billing"],
|
||||||
|
queryFn: () => getBilling(),
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["billing-plans"],
|
||||||
|
queryFn: () => getBillingPlans(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchLicense = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["license"],
|
||||||
|
queryFn: () => getLicenseInfo(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchSsoProviders = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["sso-providers"],
|
||||||
|
queryFn: () => getSsoProviders(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchShares = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["share-list", {}],
|
||||||
|
queryFn: () => getShares({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchApiKeys = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["api-key-list", {}],
|
||||||
|
queryFn: () => getApiKeys({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchApiKeyManagement = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["api-key-list", { adminView: true }],
|
||||||
|
queryFn: () => getApiKeys({ adminView: true }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchAuditLogs = () => {
|
||||||
|
const params = { limit: 50 };
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["audit-logs", params],
|
||||||
|
queryFn: () => getAuditLogs(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchVerifiedPages = () => {
|
||||||
|
const params = { limit: 50 };
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["verification-list", params],
|
||||||
|
queryFn: () => getVerificationList(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefetchScimTokens = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["scim-token-list", { cursor: undefined }],
|
||||||
|
queryFn: () => getScimTokens({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
|
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconUser,
|
IconUser,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -8,20 +8,55 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconSpaces,
|
IconSpaces,
|
||||||
IconBrush,
|
IconBrush,
|
||||||
|
IconCoin,
|
||||||
|
IconLock,
|
||||||
|
IconKey,
|
||||||
|
IconWorld,
|
||||||
|
IconSparkles,
|
||||||
|
IconHistory,
|
||||||
|
IconShieldCheck,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
import {
|
||||||
|
prefetchApiKeyManagement,
|
||||||
|
prefetchApiKeys,
|
||||||
|
prefetchBilling,
|
||||||
|
prefetchGroups,
|
||||||
|
prefetchLicense,
|
||||||
|
prefetchScimTokens,
|
||||||
|
prefetchShares,
|
||||||
|
prefetchSpaces,
|
||||||
|
prefetchSsoProviders,
|
||||||
|
prefetchWorkspaceMembers,
|
||||||
|
prefetchAuditLogs,
|
||||||
|
prefetchVerifiedPages,
|
||||||
|
} from "@/components/settings/settings-queries.tsx";
|
||||||
|
import AppVersion from "@/components/settings/app-version.tsx";
|
||||||
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
|
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||||
|
|
||||||
interface DataItem {
|
type DataItem = {
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
feature?: string;
|
||||||
|
role?: "admin" | "owner";
|
||||||
|
env?: "cloud" | "selfhosted";
|
||||||
|
};
|
||||||
|
|
||||||
interface DataGroup {
|
type DataGroup = {
|
||||||
heading: string;
|
heading: string;
|
||||||
items: DataItem[];
|
items: DataItem[];
|
||||||
}
|
};
|
||||||
|
|
||||||
const groupedData: DataGroup[] = [
|
const groupedData: DataGroup[] = [
|
||||||
{
|
{
|
||||||
@@ -33,66 +68,252 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconBrush,
|
icon: IconBrush,
|
||||||
path: "/settings/account/preferences",
|
path: "/settings/account/preferences",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "API keys",
|
||||||
|
icon: IconKey,
|
||||||
|
path: "/settings/account/api-keys",
|
||||||
|
feature: Feature.API_KEYS,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "Workspace",
|
heading: "Workspace",
|
||||||
items: [
|
items: [
|
||||||
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
||||||
|
{ label: "Members", icon: IconUsers, path: "/settings/members" },
|
||||||
{
|
{
|
||||||
label: "Members",
|
label: "Billing",
|
||||||
icon: IconUsers,
|
icon: IconCoin,
|
||||||
path: "/settings/members",
|
path: "/settings/billing",
|
||||||
|
role: "admin",
|
||||||
|
env: "cloud",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Security & SSO",
|
||||||
|
icon: IconLock,
|
||||||
|
path: "/settings/security",
|
||||||
|
feature: Feature.SECURITY_SETTINGS,
|
||||||
|
role: "admin",
|
||||||
},
|
},
|
||||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
|
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||||
|
{
|
||||||
|
label: "Verified pages",
|
||||||
|
icon: IconShieldCheck,
|
||||||
|
path: "/settings/verifications",
|
||||||
|
feature: Feature.PAGE_VERIFICATION,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "API management",
|
||||||
|
icon: IconKey,
|
||||||
|
path: "/settings/api-keys",
|
||||||
|
feature: Feature.API_KEYS,
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "AI settings",
|
||||||
|
icon: IconSparkles,
|
||||||
|
path: "/settings/ai",
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Audit log",
|
||||||
|
icon: IconHistory,
|
||||||
|
path: "/settings/audit",
|
||||||
|
feature: Feature.AUDIT_LOGS,
|
||||||
|
role: "owner",
|
||||||
|
env: "selfhosted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "System",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "License & Edition",
|
||||||
|
icon: IconKey,
|
||||||
|
path: "/settings/license",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsSidebar() {
|
export default function SettingsSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
const [active, setActive] = useState(location.pathname);
|
||||||
const navigate = useNavigate();
|
const { goBack } = useSettingsNavigation();
|
||||||
|
const { isAdmin, isOwner } = useUserRole();
|
||||||
|
const [entitlements] = useAtom(entitlementAtom);
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive(location.pathname);
|
setActive(location.pathname);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const menuItems = groupedData.map((group) => (
|
const hasFeature = (f: string) =>
|
||||||
<div key={group.heading}>
|
entitlements?.features?.includes(f) ?? false;
|
||||||
<Text c="dimmed" className={classes.linkHeader}>
|
|
||||||
{group.heading}
|
const canShowItem = (item: DataItem) => {
|
||||||
</Text>
|
if (item.env === "cloud" && !isCloud()) return false;
|
||||||
{group.items.map((item) => (
|
if (item.env === "selfhosted" && isCloud()) return false;
|
||||||
<Link
|
if (item.role === "admin" && !isAdmin) return false;
|
||||||
className={classes.link}
|
if (item.role === "owner" && !isOwner) return false;
|
||||||
data-active={active.startsWith(item.path) || undefined}
|
return true;
|
||||||
key={item.label}
|
};
|
||||||
to={item.path}
|
|
||||||
>
|
const isItemDisabled = (item: DataItem) => {
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
if (!item.feature) return false;
|
||||||
<span>{item.label}</span>
|
return !hasFeature(item.feature);
|
||||||
</Link>
|
};
|
||||||
))}
|
|
||||||
</div>
|
const menuItems = groupedData.map((group) => {
|
||||||
));
|
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.heading}>
|
||||||
|
<Text c="dimmed" className={classes.linkHeader}>
|
||||||
|
{t(group.heading)}
|
||||||
|
</Text>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
if (!canShowItem(item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefetchHandler: any;
|
||||||
|
switch (item.label) {
|
||||||
|
case "Members":
|
||||||
|
prefetchHandler = prefetchWorkspaceMembers;
|
||||||
|
break;
|
||||||
|
case "Spaces":
|
||||||
|
prefetchHandler = prefetchSpaces;
|
||||||
|
break;
|
||||||
|
case "Groups":
|
||||||
|
prefetchHandler = prefetchGroups;
|
||||||
|
break;
|
||||||
|
case "Billing":
|
||||||
|
prefetchHandler = prefetchBilling;
|
||||||
|
break;
|
||||||
|
case "License & Edition":
|
||||||
|
if (entitlements?.tier !== "free") {
|
||||||
|
prefetchHandler = prefetchLicense;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Security & SSO":
|
||||||
|
prefetchHandler = () => {
|
||||||
|
prefetchSsoProviders();
|
||||||
|
prefetchScimTokens();
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "Public sharing":
|
||||||
|
prefetchHandler = prefetchShares;
|
||||||
|
break;
|
||||||
|
case "API keys":
|
||||||
|
prefetchHandler = prefetchApiKeys;
|
||||||
|
break;
|
||||||
|
case "API management":
|
||||||
|
prefetchHandler = prefetchApiKeyManagement;
|
||||||
|
break;
|
||||||
|
case "Audit log":
|
||||||
|
prefetchHandler = prefetchAuditLogs;
|
||||||
|
break;
|
||||||
|
case "Verified pages":
|
||||||
|
prefetchHandler = prefetchVerifiedPages;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = isItemDisabled(item);
|
||||||
|
|
||||||
|
if (isDisabled) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={item.label}
|
||||||
|
label={upgradeLabel}
|
||||||
|
position="right"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={classes.link}
|
||||||
|
data-disabled
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={0}
|
||||||
|
style={{
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
onMouseEnter={prefetchHandler}
|
||||||
|
className={classes.link}
|
||||||
|
data-active={active.startsWith(item.path) || undefined}
|
||||||
|
key={item.label}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => {
|
||||||
|
if (mobileSidebarOpened) {
|
||||||
|
toggleMobileSidebar();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className={classes.linkIcon} stroke={2} />
|
||||||
|
<span>{t(item.label)}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
<Group className={classes.title} justify="flex-start">
|
<Group className={classes.title} justify="flex-start">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => {
|
||||||
|
goBack();
|
||||||
|
if (mobileSidebarOpened) {
|
||||||
|
toggleMobileSidebar();
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label="Back"
|
aria-label={t("Back")}
|
||||||
>
|
>
|
||||||
<IconArrowLeft stroke={2} />
|
<IconArrowLeft stroke={2} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Text fw={500}>Settings</Text>
|
<Text fw={500}>{t("Settings")}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||||
|
|
||||||
|
{!isCloud() && <AppVersion />}
|
||||||
|
|
||||||
|
{isCloud() && (
|
||||||
|
<div className={classes.text}>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component="a"
|
||||||
|
href="mailto:help@docmost.com"
|
||||||
|
>
|
||||||
|
help@docmost.com
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,3 +57,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
padding-left: var(--mantine-spacing-xs) ;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.dark {
|
||||||
|
@mixin dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
@mixin light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,28 @@
|
|||||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
useComputedColorScheme,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||||
|
import classes from "./theme-toggle.module.css";
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="center" mt="xl">
|
<Tooltip label="Toggle Color Scheme">
|
||||||
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
<ActionIcon
|
||||||
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
variant="default"
|
||||||
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
onClick={() => {
|
||||||
</Group>
|
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
||||||
);
|
}}
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
>
|
||||||
|
<IconSun className={classes.light} size={18} stroke={1.5} />
|
||||||
|
<IconMoon className={classes.dark} size={18} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useRef, useState, ReactNode } from "react";
|
||||||
|
import { Text, TextProps, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
type AutoTooltipTextProps = TextProps & {
|
||||||
|
children: ReactNode;
|
||||||
|
tooltipLabel?: string;
|
||||||
|
tooltipProps?: Omit<
|
||||||
|
React.ComponentProps<typeof Tooltip>,
|
||||||
|
"children" | "label"
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutoTooltipText({
|
||||||
|
children,
|
||||||
|
tooltipLabel,
|
||||||
|
tooltipProps,
|
||||||
|
...textProps
|
||||||
|
}: AutoTooltipTextProps) {
|
||||||
|
const textRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
const element = textRef.current;
|
||||||
|
if (element) {
|
||||||
|
setIsTruncated(element.scrollWidth > element.clientWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={label}
|
||||||
|
disabled={!isTruncated || !label}
|
||||||
|
multiline
|
||||||
|
withArrow
|
||||||
|
withinPortal={false}
|
||||||
|
{...tooltipProps}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
ref={textRef}
|
||||||
|
truncate
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
{...textProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--mantine-spacing-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
padding: 2px;
|
||||||
|
margin: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track > * {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
|
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root:hover .arrow.visible,
|
||||||
|
.arrow.visible:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow:hover {
|
||||||
|
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow:active {
|
||||||
|
transform: translateY(-50%) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowLeft {
|
||||||
|
left: -14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowRight {
|
||||||
|
right: -14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "./card-carousel.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CardCarousel({ children, ariaLabel }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||||
|
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||||
|
setCanScrollLeft(el.scrollLeft > 1);
|
||||||
|
setCanScrollRight(el.scrollLeft < maxScroll - 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollState();
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateScrollState);
|
||||||
|
observer.observe(el);
|
||||||
|
for (const child of Array.from(el.children)) {
|
||||||
|
observer.observe(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [updateScrollState, children]);
|
||||||
|
|
||||||
|
const scrollBy = (direction: 1 | -1) => {
|
||||||
|
const el = trackRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className={classes.track}
|
||||||
|
onScroll={updateScrollState}
|
||||||
|
{...(ariaLabel ? { role: "region", "aria-label": ariaLabel } : {})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${classes.arrow} ${classes.arrowLeft} ${canScrollLeft ? classes.visible : ""}`}
|
||||||
|
onClick={() => scrollBy(-1)}
|
||||||
|
aria-label={t("Scroll left")}
|
||||||
|
tabIndex={canScrollLeft ? 0 : -1}
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${classes.arrow} ${classes.arrowRight} ${canScrollRight ? classes.visible : ""}`}
|
||||||
|
onClick={() => scrollBy(1)}
|
||||||
|
aria-label={t("Scroll right")}
|
||||||
|
tabIndex={canScrollRight ? 0 : -1}
|
||||||
|
>
|
||||||
|
<IconChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Avatar } from "@mantine/core";
|
import { Avatar, MantineColor } from "@mantine/core";
|
||||||
import { getAvatarUrl } from "@/lib/config.ts";
|
import { getAvatarUrl } from "@/lib/config.ts";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
interface CustomAvatarProps {
|
interface CustomAvatarProps {
|
||||||
avatarUrl: string;
|
avatarUrl?: string;
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
@@ -11,21 +12,57 @@ interface CustomAvatarProps {
|
|||||||
variant?: string;
|
variant?: string;
|
||||||
style?: any;
|
style?: any;
|
||||||
component?: any;
|
component?: any;
|
||||||
|
type?: AvatarIconType;
|
||||||
|
mt?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `color.shade` pairs whose filled background meets WCAG AA (4.5:1) against
|
||||||
|
// white text. Avoids lime/yellow/green/orange — even their dark shades have
|
||||||
|
// weak white-text contrast.
|
||||||
|
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||||
|
"blue.8",
|
||||||
|
"cyan.9",
|
||||||
|
"grape.7",
|
||||||
|
"indigo.7",
|
||||||
|
"pink.8",
|
||||||
|
"red.8",
|
||||||
|
"violet.7",
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashName(input: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickInitialsColor(name: string) {
|
||||||
|
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeInitialsSource(name: string) {
|
||||||
|
const sanitized = name.replace(/[^\p{L}\p{N}\s]/gu, " ").trim();
|
||||||
|
return sanitized || name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomAvatar = React.forwardRef<
|
export const CustomAvatar = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
CustomAvatarProps
|
CustomAvatarProps
|
||||||
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
|
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
|
||||||
const avatarLink = getAvatarUrl(avatarUrl);
|
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||||
|
const resolvedColor =
|
||||||
|
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||||
|
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={avatarLink}
|
src={avatarLink}
|
||||||
name={name}
|
name={initialsSource}
|
||||||
alt={name}
|
alt={name}
|
||||||
color="initials"
|
color={resolvedColor}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Modal, Button, Group } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DestinationPicker } from "./destination-picker";
|
||||||
|
import {
|
||||||
|
DestinationPickerModalProps,
|
||||||
|
DestinationSelection,
|
||||||
|
} from "./destination-picker.types";
|
||||||
|
|
||||||
|
export function DestinationPickerModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
actionLabel,
|
||||||
|
onSelect,
|
||||||
|
loading,
|
||||||
|
excludePageId,
|
||||||
|
pageLimit,
|
||||||
|
}: DestinationPickerModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) {
|
||||||
|
setSelection(null);
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal.Root
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
size={550}
|
||||||
|
padding="lg"
|
||||||
|
yOffset="10vh"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content>
|
||||||
|
<Modal.Header py={0}>
|
||||||
|
<Modal.Title fw={500}>{title}</Modal.Title>
|
||||||
|
<Modal.CloseButton />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<DestinationPicker
|
||||||
|
onSelectionChange={setSelection}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
pageLimit={pageLimit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{t("Close")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => selection && onSelect(selection)}
|
||||||
|
disabled={!selection}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
.searchInput {
|
||||||
|
margin-bottom: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollArea {
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-0),
|
||||||
|
var(--mantine-color-dark-6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-blue-0),
|
||||||
|
var(--mantine-color-dark-5)
|
||||||
|
);
|
||||||
|
border-left: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spaceRow {
|
||||||
|
composes: row;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageRow {
|
||||||
|
composes: row;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-1),
|
||||||
|
var(--mantine-color-dark-5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronExpanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMore {
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px;
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedIndicator {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
margin-top: var(--mantine-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchResult {
|
||||||
|
composes: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spaceName {
|
||||||
|
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrapper {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconSearch, IconFile } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||||
|
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||||
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { DestinationSelection } from "./destination-picker.types";
|
||||||
|
import { SpaceRow } from "./space-row";
|
||||||
|
import classes from "./destination-picker.module.css";
|
||||||
|
|
||||||
|
type DestinationPickerProps = {
|
||||||
|
onSelectionChange: (selection: DestinationSelection | null) => void;
|
||||||
|
excludePageId?: string;
|
||||||
|
pageLimit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DestinationPicker({
|
||||||
|
onSelectionChange,
|
||||||
|
excludePageId,
|
||||||
|
pageLimit = 15,
|
||||||
|
}: DestinationPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||||
|
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
|
||||||
|
|
||||||
|
const { data: searchData, isLoading: searchLoading } =
|
||||||
|
useSearchSuggestionsQuery({
|
||||||
|
query: searchEnabled ? debouncedQuery : "",
|
||||||
|
includePages: true,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSearching = !!searchEnabled;
|
||||||
|
|
||||||
|
const selectedId =
|
||||||
|
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
||||||
|
|
||||||
|
const updateSelection = useCallback(
|
||||||
|
(next: DestinationSelection | null) => {
|
||||||
|
setSelection(next);
|
||||||
|
onSelectionChange(next);
|
||||||
|
},
|
||||||
|
[onSelectionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchResultClick = (page: Partial<IPage>) => {
|
||||||
|
if (!page.space || !page.id) return;
|
||||||
|
|
||||||
|
updateSelection({
|
||||||
|
type: "page",
|
||||||
|
spaceId: page.space.id,
|
||||||
|
pageId: page.id,
|
||||||
|
page,
|
||||||
|
space: page.space,
|
||||||
|
});
|
||||||
|
setSearchQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSpace = useCallback(
|
||||||
|
(space: ISpace) => {
|
||||||
|
updateSelection({ type: "space", spaceId: space.id, space });
|
||||||
|
},
|
||||||
|
[updateSelection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectPage = useCallback(
|
||||||
|
(page: Partial<IPage>, space: ISpace) => {
|
||||||
|
if (!page.id) return;
|
||||||
|
updateSelection({
|
||||||
|
type: "page",
|
||||||
|
spaceId: page.spaceId ?? space.id,
|
||||||
|
pageId: page.id,
|
||||||
|
page,
|
||||||
|
space,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateSelection],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
placeholder={t("Search pages and spaces...")}
|
||||||
|
variant="filled"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
className={classes.searchInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
|
||||||
|
{isSearching ? (
|
||||||
|
searchLoading ? (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</div>
|
||||||
|
) : searchData?.pages && searchData.pages.length > 0 ? (
|
||||||
|
searchData.pages.map(
|
||||||
|
(page) =>
|
||||||
|
page && (
|
||||||
|
<div
|
||||||
|
key={page.id}
|
||||||
|
className={classes.searchResult}
|
||||||
|
onClick={() => handleSearchResultClick(page)}
|
||||||
|
>
|
||||||
|
<div className={classes.iconWrapper}>
|
||||||
|
{page.icon ? (
|
||||||
|
page.icon
|
||||||
|
) : (
|
||||||
|
<IconFile
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-gray-5)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={classes.pageTitle}>
|
||||||
|
{page.title || t("Untitled")}
|
||||||
|
</div>
|
||||||
|
{page.space && (
|
||||||
|
<div className={classes.spaceName}>
|
||||||
|
{page.space.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className={classes.emptyState}>{t("No results found")}</div>
|
||||||
|
)
|
||||||
|
) : spacesLoading ? (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
spacesData?.items?.map((space) => (
|
||||||
|
<SpaceRow
|
||||||
|
key={space.id}
|
||||||
|
space={space}
|
||||||
|
limit={pageLimit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
onSelectSpace={handleSelectSpace}
|
||||||
|
onSelectPage={handleSelectPage}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{selection && (
|
||||||
|
<div className={classes.selectedIndicator}>
|
||||||
|
{selection.type === "space"
|
||||||
|
? selection.space.name
|
||||||
|
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
|
||||||
|
export type DestinationSelection =
|
||||||
|
| { type: "space"; spaceId: string; space: ISpace }
|
||||||
|
| {
|
||||||
|
type: "page";
|
||||||
|
spaceId: string;
|
||||||
|
pageId: string;
|
||||||
|
page: Partial<IPage>;
|
||||||
|
space: Partial<ISpace>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DestinationPickerModalProps = {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onSelect: (selection: DestinationSelection) => void | Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
excludePageId?: string;
|
||||||
|
pageLimit?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Loader } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getSidebarPages } from "@/features/page/services/page-service";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { IPagination } from "@/lib/types";
|
||||||
|
import { PageRow } from "./page-row";
|
||||||
|
import classes from "./destination-picker.module.css";
|
||||||
|
|
||||||
|
type PageChildrenProps = {
|
||||||
|
spaceId: string;
|
||||||
|
pageId?: string;
|
||||||
|
depth: number;
|
||||||
|
limit: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
excludePageId?: string;
|
||||||
|
onSelectPage: (page: Partial<IPage>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageChildren({
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
depth,
|
||||||
|
limit,
|
||||||
|
selectedId,
|
||||||
|
excludePageId,
|
||||||
|
onSelectPage,
|
||||||
|
}: PageChildrenProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ["destination-pages", spaceId, pageId ?? "root"],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
getSidebarPages({
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
limit,
|
||||||
|
cursor: pageParam,
|
||||||
|
}),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage: IPagination<IPage>) =>
|
||||||
|
lastPage.meta?.nextCursor ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
{pageId ? t("No pages inside") : t("No pages in this space")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pages.map((page) => (
|
||||||
|
<PageRow
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
depth={depth}
|
||||||
|
limit={limit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
onSelect={onSelectPage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<div
|
||||||
|
className={classes.loadMore}
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{t("Load more")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { IconChevronRight, IconFile } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { PageChildren } from "./page-children";
|
||||||
|
import classes from "./destination-picker.module.css";
|
||||||
|
|
||||||
|
type PageRowProps = {
|
||||||
|
page: Partial<IPage>;
|
||||||
|
depth: number;
|
||||||
|
limit: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
excludePageId?: string;
|
||||||
|
onSelect: (page: Partial<IPage>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageRow({
|
||||||
|
page,
|
||||||
|
depth,
|
||||||
|
limit,
|
||||||
|
selectedId,
|
||||||
|
excludePageId,
|
||||||
|
onSelect,
|
||||||
|
}: PageRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const isExcluded = page.id === excludePageId;
|
||||||
|
const isSelected = page.id === selectedId;
|
||||||
|
|
||||||
|
const rowClasses = [
|
||||||
|
classes.pageRow,
|
||||||
|
isSelected && classes.selected,
|
||||||
|
isExcluded && classes.disabled,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={rowClasses}
|
||||||
|
style={{ paddingLeft: depth * 20 + 12 }}
|
||||||
|
onClick={() => !isExcluded && onSelect(page)}
|
||||||
|
>
|
||||||
|
{page.hasChildren ? (
|
||||||
|
<div
|
||||||
|
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded(!expanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classes.iconWrapper}>
|
||||||
|
{page.icon ? (
|
||||||
|
page.icon
|
||||||
|
) : (
|
||||||
|
<IconFile
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-gray-5)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.pageTitle}>
|
||||||
|
{page.title || t("Untitled")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && page.hasChildren && (
|
||||||
|
<PageChildren
|
||||||
|
spaceId={page.spaceId}
|
||||||
|
pageId={page.id}
|
||||||
|
depth={depth + 1}
|
||||||
|
limit={limit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
onSelectPage={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Tooltip } from "@mantine/core";
|
||||||
|
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { SpaceRole } from "@/lib/types";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||||
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||||
|
import { PageChildren } from "./page-children";
|
||||||
|
import classes from "./destination-picker.module.css";
|
||||||
|
|
||||||
|
type SpaceRowProps = {
|
||||||
|
space: ISpace;
|
||||||
|
limit: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
excludePageId?: string;
|
||||||
|
onSelectSpace: (space: ISpace) => void;
|
||||||
|
onSelectPage: (page: Partial<IPage>, space: ISpace) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SpaceRow({
|
||||||
|
space,
|
||||||
|
limit,
|
||||||
|
selectedId,
|
||||||
|
excludePageId,
|
||||||
|
onSelectSpace,
|
||||||
|
onSelectPage,
|
||||||
|
}: SpaceRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const writable =
|
||||||
|
!!space.membership?.role && space.membership.role !== SpaceRole.READER;
|
||||||
|
const isSelected = space.id === selectedId;
|
||||||
|
|
||||||
|
const rowClasses = [
|
||||||
|
classes.spaceRow,
|
||||||
|
isSelected && classes.selected,
|
||||||
|
!writable && classes.disabled,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const rowContent = (
|
||||||
|
<div
|
||||||
|
className={rowClasses}
|
||||||
|
onClick={() => writable && onSelectSpace(space)}
|
||||||
|
>
|
||||||
|
{writable ? (
|
||||||
|
<div
|
||||||
|
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded(!expanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomAvatar
|
||||||
|
name={space.name}
|
||||||
|
avatarUrl={space.logo}
|
||||||
|
type={AvatarIconType.SPACE_ICON}
|
||||||
|
size={22}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={classes.pageTitle}>{space.name}</div>
|
||||||
|
|
||||||
|
{!writable && (
|
||||||
|
<IconLock
|
||||||
|
size={14}
|
||||||
|
color="var(--mantine-color-gray-5)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{writable ? (
|
||||||
|
rowContent
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
label={t("You don't have permission to create pages here")}
|
||||||
|
position="right"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<div>{rowContent}</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && writable && (
|
||||||
|
<PageChildren
|
||||||
|
spaceId={space.id}
|
||||||
|
depth={1}
|
||||||
|
limit={limit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
excludePageId={excludePageId}
|
||||||
|
onSelectPage={(page) => onSelectPage(page, space)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode, useState } from "react";
|
||||||
import data from "@emoji-mart/data";
|
|
||||||
import Picker from "@emoji-mart/react";
|
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Popover,
|
Popover,
|
||||||
Button,
|
Button,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
const Picker = React.lazy(() => import("@emoji-mart/react"));
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface EmojiPickerInterface {
|
export interface EmojiPickerInterface {
|
||||||
onEmojiSelect: (emoji: any) => void;
|
onEmojiSelect: (emoji: any) => void;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
removeEmojiAction: () => void;
|
removeEmojiAction: () => void;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
actionIconProps?: {
|
||||||
|
size?: string;
|
||||||
|
variant?: string;
|
||||||
|
c?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiPicker({
|
function EmojiPicker({
|
||||||
@@ -21,9 +27,28 @@ function EmojiPicker({
|
|||||||
icon,
|
icon,
|
||||||
removeEmojiAction,
|
removeEmojiAction,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
actionIconProps,
|
||||||
}: EmojiPickerInterface) {
|
}: EmojiPickerInterface) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [opened, handlers] = useDisclosure(false);
|
const [opened, handlers] = useDisclosure(false);
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const [target, setTarget] = useState<HTMLElement | null>(null);
|
||||||
|
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useClickOutside(
|
||||||
|
() => handlers.close(),
|
||||||
|
["mousedown", "touchstart"],
|
||||||
|
[dropdown, target],
|
||||||
|
);
|
||||||
|
|
||||||
|
// We need this because the default Mantine popover closeOnEscape does not work
|
||||||
|
useWindowEvent("keydown", (event) => {
|
||||||
|
if (opened && event.key === "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
handlers.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji) => {
|
const handleEmojiSelect = (emoji) => {
|
||||||
onEmojiSelect(emoji);
|
onEmojiSelect(emoji);
|
||||||
@@ -42,35 +67,46 @@ function EmojiPicker({
|
|||||||
width={332}
|
width={332}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
|
closeOnEscape={true}
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target ref={setTarget}>
|
||||||
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
|
<ActionIcon
|
||||||
|
c={actionIconProps?.c || "gray"}
|
||||||
|
variant={actionIconProps?.variant || "transparent"}
|
||||||
|
size={actionIconProps?.size}
|
||||||
|
onClick={handlers.toggle}
|
||||||
|
aria-label={t("Pick emoji")}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={opened}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown bg="000" style={{ border: "none" }}>
|
<Suspense fallback={null}>
|
||||||
<Picker
|
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
|
||||||
data={data}
|
<Picker
|
||||||
onEmojiSelect={handleEmojiSelect}
|
data={async () => (await import("@emoji-mart/data")).default}
|
||||||
perLine={8}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
skinTonePosition="search"
|
perLine={8}
|
||||||
theme={colorScheme}
|
skinTonePosition="search"
|
||||||
/>
|
theme={colorScheme}
|
||||||
<Button
|
/>
|
||||||
variant="default"
|
<Button
|
||||||
c="gray"
|
variant="default"
|
||||||
size="xs"
|
c="gray"
|
||||||
style={{
|
size="xs"
|
||||||
position: "absolute",
|
style={{
|
||||||
zIndex: 2,
|
position: "absolute",
|
||||||
bottom: "1rem",
|
zIndex: 2,
|
||||||
right: "1rem",
|
bottom: "1rem",
|
||||||
}}
|
right: "1rem",
|
||||||
onClick={handleRemoveEmoji}
|
}}
|
||||||
>
|
onClick={handleRemoveEmoji}
|
||||||
Remove
|
>
|
||||||
</Button>
|
{t("Remove")}
|
||||||
</Popover.Dropdown>
|
</Button>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Suspense>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
import { type TablerIcon } from "@tabler/icons-react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import classes from "./empty-state.module.css";
|
||||||
|
|
||||||
|
type EmptyStateProps = {
|
||||||
|
icon: TablerIcon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<Icon size={40} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||||
|
<Text size="lg" fw={500}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{description && (
|
||||||
|
<Text size="sm" c="dimmed" maw={350}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{action}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
import { Title, Text, Button, Container, Group } from "@mantine/core";
|
import { Title, Text, Button, Container, Group } from "@mantine/core";
|
||||||
import classes from "./error-404.module.css";
|
import classes from "./error-404.module.css";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function Error404() {
|
export function Error404() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className={classes.root}>
|
<>
|
||||||
<Title className={classes.title}>404 Page Not Found</Title>
|
<Helmet>
|
||||||
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
<title>{t("404 page not found")} - Docmost</title>
|
||||||
Sorry, we can't find the page you are looking for.
|
</Helmet>
|
||||||
</Text>
|
<Container className={classes.root}>
|
||||||
<Group justify="center">
|
<Title className={classes.title}>{t("404 page not found")}</Title>
|
||||||
<Button component={Link} to={"/home"} variant="subtle" size="md">
|
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
||||||
Take me back to homepage
|
{t("Sorry, we can't find the page you are looking for.")}
|
||||||
</Button>
|
</Text>
|
||||||
</Group>
|
<Group justify="center">
|
||||||
</Container>
|
<Button component={Link} to={"/home"} variant="subtle" size="md">
|
||||||
|
{t("Take me back to homepage")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ResponsiveSettingsRowProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveSettingsRow({ children }: ResponsiveSettingsRowProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponsiveSettingsContentProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveSettingsContent({ children }: ResponsiveSettingsContentProps) {
|
||||||
|
return (
|
||||||
|
<Box style={{ flex: "1 1 300px", minWidth: 0 }}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponsiveSettingsControlProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveSettingsControl({ children }: ResponsiveSettingsControlProps) {
|
||||||
|
return (
|
||||||
|
<Box style={{ flex: "0 0 auto" }}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { forwardRef } from "react";
|
|||||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||||
import { Group, Text, Menu, Button } from "@mantine/core";
|
import { Group, Text, Menu, Button } from "@mantine/core";
|
||||||
import { IRoleData } from "@/lib/types.ts";
|
import { IRoleData } from "@/lib/types.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,7 +24,7 @@ const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
interface SpaceRoleMenuProps {
|
interface RoleMenuProps {
|
||||||
roles: IRoleData[];
|
roles: IRoleData[];
|
||||||
roleName: string;
|
roleName: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
@@ -35,11 +36,13 @@ export default function RoleSelectMenu({
|
|||||||
roleName,
|
roleName,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: SpaceRoleMenuProps) {
|
}: RoleMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withArrow>
|
<Menu withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<RoleButton name={roleName} disabled={disabled} />
|
<RoleButton name={t(roleName)} disabled={disabled} />
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
@@ -50,9 +53,9 @@ export default function RoleSelectMenu({
|
|||||||
>
|
>
|
||||||
<Group flex="1" gap="xs">
|
<Group flex="1" gap="xs">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm">{item.label}</Text>
|
<Text size="sm">{t(item.label)}</Text>
|
||||||
<Text size="xs" opacity={0.65}>
|
<Text size="xs" opacity={0.65}>
|
||||||
{item.description}
|
{t(item.description)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{item.label === roleName && <IconCheck size={20} />}
|
{item.label === roleName && <IconCheck size={20} />}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
IconLayoutSidebarRightCollapse,
|
IconLayoutSidebarRightCollapse,
|
||||||
IconLayoutSidebarRightExpand,
|
IconLayoutSidebarRightExpand
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import { ActionIcon, BoxProps, ElementProps, MantineColor, MantineSize } from "@mantine/core";
|
||||||
ActionIcon,
|
|
||||||
BoxProps,
|
|
||||||
ElementProps,
|
|
||||||
MantineColor,
|
|
||||||
MantineSize,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
||||||
size?: MantineSize | `compact-${MantineSize}` | (string & {});
|
size?: MantineSize | `compact-${MantineSize}` | (string & {});
|
||||||
@@ -17,18 +11,18 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
|||||||
opened?: boolean;
|
opened?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SidebarToggle({
|
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
|
||||||
opened,
|
({ opened, size = "sm", ...others }, ref) => {
|
||||||
size = "sm",
|
return (
|
||||||
...others
|
<ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
|
||||||
}: SidebarToggleProps) {
|
{opened ? (
|
||||||
return (
|
<IconLayoutSidebarRightExpand />
|
||||||
<ActionIcon size={size} {...others} variant="subtle" color="gray">
|
) : (
|
||||||
{opened ? (
|
<IconLayoutSidebarRightCollapse />
|
||||||
<IconLayoutSidebarRightExpand />
|
)}
|
||||||
) : (
|
</ActionIcon>
|
||||||
<IconLayoutSidebarRightCollapse />
|
);
|
||||||
)}
|
}
|
||||||
</ActionIcon>
|
);
|
||||||
);
|
|
||||||
}
|
export default SidebarToggle;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Files in this directory are subject to the Docmost Enterprise Edition license.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useChatInfoQuery } from "../queries/ai-chat-query";
|
||||||
|
import { useChatStream } from "../hooks/use-chat-stream";
|
||||||
|
import ChatMessageList from "./chat-message-list";
|
||||||
|
import ChatEmptyState from "./chat-empty-state";
|
||||||
|
import ChatInput from "./chat-input";
|
||||||
|
import type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt";
|
||||||
|
import classes from "../styles/ai-chat.module.css";
|
||||||
|
|
||||||
|
export default function AiChatLayout() {
|
||||||
|
const { chatId } = useParams<{ chatId: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const chatInfoQuery = useChatInfoQuery(chatId);
|
||||||
|
|
||||||
|
// If the URL points at a chat the user does not own, the info fetch 404s.
|
||||||
|
// Bounce them back to /ai so they cannot interact with any chat UI (including
|
||||||
|
// kicking off orphan uploads) tied to a chat they have no access to.
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatId && chatInfoQuery.isError) {
|
||||||
|
navigate("/ai", { replace: true });
|
||||||
|
}
|
||||||
|
}, [chatId, chatInfoQuery.isError, navigate]);
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
streamingContent,
|
||||||
|
streamingToolCalls,
|
||||||
|
isStreaming,
|
||||||
|
error,
|
||||||
|
sendMessage,
|
||||||
|
stopGeneration,
|
||||||
|
hydrateFromServer,
|
||||||
|
} = useChatStream(chatId);
|
||||||
|
|
||||||
|
const autoSentRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatInfoQuery.data?.messages) {
|
||||||
|
hydrateFromServer(chatInfoQuery.data.messages);
|
||||||
|
}
|
||||||
|
}, [chatInfoQuery.data, hydrateFromServer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoSentRef.current || chatId) return;
|
||||||
|
const state = location.state as HomeAiPromptInitialState | null;
|
||||||
|
if (!state?.initialContent && !state?.initialAttachments?.length) return;
|
||||||
|
|
||||||
|
autoSentRef.current = true;
|
||||||
|
sendMessage(
|
||||||
|
state.initialContent ?? "",
|
||||||
|
state.initialMentions ?? [],
|
||||||
|
state.initialAttachments ?? [],
|
||||||
|
);
|
||||||
|
navigate(location.pathname, { replace: true, state: null });
|
||||||
|
}, [chatId, location, navigate, sendMessage]);
|
||||||
|
|
||||||
|
const hasMessages = messages.length > 0 || isStreaming || !!chatId;
|
||||||
|
|
||||||
|
// While the redirect effect is running (or if the user is still on this
|
||||||
|
// component for any reason) never render the chat UI for a forbidden chat.
|
||||||
|
if (chatId && chatInfoQuery.isError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.main}>
|
||||||
|
{hasMessages ? (
|
||||||
|
<>
|
||||||
|
<ChatMessageList
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
streamingContent={streamingContent}
|
||||||
|
streamingToolCalls={streamingToolCalls}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-lg)",
|
||||||
|
color: "var(--mantine-color-red-6)",
|
||||||
|
fontSize: "var(--mantine-font-size-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={classes.inputArea}>
|
||||||
|
<ChatInput
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onSend={sendMessage}
|
||||||
|
onStop={stopGeneration}
|
||||||
|
chatId={chatId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ChatEmptyState
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
onSend={sendMessage}
|
||||||
|
onStop={stopGeneration}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import { ActionIcon, Menu, TextInput } from "@mantine/core";
|
||||||
|
import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { AiChat } from "../types/ai-chat.types";
|
||||||
|
import classes from "../styles/chat-sidebar.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
chat: AiChat;
|
||||||
|
isActive: boolean;
|
||||||
|
onDelete: (chatId: string, title: string | null) => void;
|
||||||
|
onRename: (chatId: string, title: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatChatDate(
|
||||||
|
isoString: string | Date,
|
||||||
|
locale: string | undefined,
|
||||||
|
): string {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startOfToday = new Date(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
now.getDate(),
|
||||||
|
).getTime();
|
||||||
|
const ts = date.getTime();
|
||||||
|
const sameYear = date.getFullYear() === now.getFullYear();
|
||||||
|
|
||||||
|
if (ts >= startOfToday) {
|
||||||
|
return date.toLocaleTimeString(locale, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameYear) {
|
||||||
|
return date.toLocaleDateString(locale, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString(locale, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiChatSidebarItem({
|
||||||
|
chat,
|
||||||
|
isActive,
|
||||||
|
onDelete,
|
||||||
|
onRename,
|
||||||
|
}: Props) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const formattedDate = useMemo(
|
||||||
|
() => formatChatDate(chat.updatedAt, i18n.language),
|
||||||
|
[chat.updatedAt, i18n.language],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renaming) {
|
||||||
|
// Wait for the input to be mounted before selecting.
|
||||||
|
const id = window.setTimeout(() => inputRef.current?.select(), 0);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}
|
||||||
|
}, [renaming]);
|
||||||
|
|
||||||
|
const startRename = useCallback(() => {
|
||||||
|
setRenameValue(chat.title || "");
|
||||||
|
setRenaming(true);
|
||||||
|
}, [chat.title]);
|
||||||
|
|
||||||
|
const submitRename = useCallback(() => {
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
if (trimmed && trimmed !== chat.title) {
|
||||||
|
onRename(chat.id, trimmed);
|
||||||
|
}
|
||||||
|
setRenaming(false);
|
||||||
|
}, [renameValue, chat.id, chat.title, onRename]);
|
||||||
|
|
||||||
|
if (renaming) {
|
||||||
|
return (
|
||||||
|
<div className={classes.chatItem} data-active={isActive || undefined}>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
size="xs"
|
||||||
|
variant="unstyled"
|
||||||
|
placeholder={t("Chat name")}
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitRename();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={submitRename}
|
||||||
|
classNames={{ input: classes.chatItemRenameInput }}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/ai/chat/${chat.id}`}
|
||||||
|
className={classes.chatItem}
|
||||||
|
data-active={isActive || undefined}
|
||||||
|
>
|
||||||
|
<span className={classes.chatItemTitle}>
|
||||||
|
{chat.title || t("Untitled chat")}
|
||||||
|
</span>
|
||||||
|
<span className={classes.chatItemDate}>{formattedDate}</span>
|
||||||
|
<div className={classes.chatItemActions}>
|
||||||
|
<Menu position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
aria-label={t("Chat menu")}
|
||||||
|
>
|
||||||
|
<IconDots size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconEdit size={14} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
startRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Rename")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconTrash size={14} />}
|
||||||
|
color="red"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(chat.id, chat.title);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Delete")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Center,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Loader,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
useChatsQuery,
|
||||||
|
useDeleteChatMutation,
|
||||||
|
useUpdateChatTitleMutation,
|
||||||
|
useSearchChatsQuery,
|
||||||
|
} from "../queries/ai-chat-query";
|
||||||
|
import AiChatSidebarItem from "./ai-chat-sidebar-item";
|
||||||
|
import { groupChatsByAge } from "../utils/group-chats-by-age";
|
||||||
|
import classes from "../styles/chat-sidebar.module.css";
|
||||||
|
|
||||||
|
export default function AiChatSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { chatId } = useParams<{ chatId: string }>();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
|
const chatsQuery = useChatsQuery();
|
||||||
|
const searchQuery = useSearchChatsQuery(debouncedSearch);
|
||||||
|
const deleteMutation = useDeleteChatMutation();
|
||||||
|
const renameMutation = useUpdateChatTitleMutation();
|
||||||
|
|
||||||
|
const chats = useMemo(() => {
|
||||||
|
if (debouncedSearch) {
|
||||||
|
return searchQuery.data || [];
|
||||||
|
}
|
||||||
|
return chatsQuery.data?.pages.flatMap((p) => p.items) || [];
|
||||||
|
}, [debouncedSearch, searchQuery.data, chatsQuery.data]);
|
||||||
|
|
||||||
|
const groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]);
|
||||||
|
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { hasNextPage, fetchNextPage, isFetchingNextPage } = chatsQuery;
|
||||||
|
const isSearching = Boolean(debouncedSearch);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSearching) return;
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
if (!sentinel) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isSearching, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
const handleNewChat = useCallback(
|
||||||
|
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.shiftKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
navigate("/ai");
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(id: string, title: string | null) => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("Delete chat"),
|
||||||
|
centered: true,
|
||||||
|
children: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
|
||||||
|
title: title || t("Untitled"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
onConfirm: () => {
|
||||||
|
deleteMutation.mutate(id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (chatId === id) {
|
||||||
|
navigate("/ai");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[deleteMutation, chatId, navigate, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(chatId: string, title: string) => {
|
||||||
|
renameMutation.mutate({ chatId, title });
|
||||||
|
},
|
||||||
|
[renameMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = chatsQuery.isLoading || searchQuery.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.sidebar}>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<span className={classes.title}>{t("AI Chat")}</span>
|
||||||
|
<Tooltip label={t("New chat")} openDelay={250} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
to="/ai"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleNewChat}
|
||||||
|
aria-label={t("New chat")}
|
||||||
|
>
|
||||||
|
<IconPlus size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className={classes.searchInput}
|
||||||
|
placeholder={t("Search chats...")}
|
||||||
|
aria-label={t("Search chats")}
|
||||||
|
leftSection={<IconSearch size={14} />}
|
||||||
|
size="xs"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={classes.chatList}>
|
||||||
|
{isLoading && <Loader size="xs" mx="auto" mt="md" />}
|
||||||
|
{!isLoading && chats.length === 0 && (
|
||||||
|
<div className={classes.chatListEmpty}>
|
||||||
|
<IconMessageCircle2
|
||||||
|
size={28}
|
||||||
|
stroke={1.5}
|
||||||
|
className={classes.chatListEmptyIcon}
|
||||||
|
/>
|
||||||
|
<div className={classes.chatListEmptyTitle}>
|
||||||
|
{isSearching ? t("No chats found") : t("No conversations yet")}
|
||||||
|
</div>
|
||||||
|
<div className={classes.chatListEmptyHint}>
|
||||||
|
{isSearching
|
||||||
|
? t("Try a different search term.")
|
||||||
|
: t("Start a new chat to see it here.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSearching
|
||||||
|
? chats.map((chat) => (
|
||||||
|
<AiChatSidebarItem
|
||||||
|
key={chat.id}
|
||||||
|
chat={chat}
|
||||||
|
isActive={chat.id === chatId}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onRename={handleRename}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: groupedChats.map((group) => (
|
||||||
|
<div key={group.key} className={classes.chatGroup}>
|
||||||
|
<div className={classes.chatGroupLabel}>{group.label}</div>
|
||||||
|
{group.chats.map((chat) => (
|
||||||
|
<AiChatSidebarItem
|
||||||
|
key={chat.id}
|
||||||
|
chat={chat}
|
||||||
|
isActive={chat.id === chatId}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onRename={handleRename}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isSearching && (
|
||||||
|
<>
|
||||||
|
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<Center py="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { TextInput, Loader, Text, ScrollArea } from "@mantine/core";
|
||||||
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
import { useChatsQuery, useSearchChatsQuery } from "../queries/ai-chat-query";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "../styles/aside-chat-panel.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
activeChatId: string | undefined;
|
||||||
|
onSelect: (chatId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AsideChatHistory({ activeChatId, onSelect }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
|
||||||
|
|
||||||
|
const chatsQuery = useChatsQuery();
|
||||||
|
const searchQuery = useSearchChatsQuery(debouncedSearch);
|
||||||
|
|
||||||
|
const isSearching = debouncedSearch.length > 0;
|
||||||
|
const chats = isSearching
|
||||||
|
? (searchQuery.data ?? [])
|
||||||
|
: (chatsQuery.data?.pages.flatMap((p) => p.items) ?? []);
|
||||||
|
const isLoading = isSearching ? searchQuery.isLoading : chatsQuery.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TextInput
|
||||||
|
placeholder={t("Search chats...")}
|
||||||
|
leftSection={<IconSearch size={14} />}
|
||||||
|
size="xs"
|
||||||
|
mb="xs"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||||
|
<Loader size="sm" />
|
||||||
|
</div>
|
||||||
|
) : chats.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
{isSearching ? t("No chats found") : t("No chat history")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ScrollArea.Autosize mah={300} scrollbars="y">
|
||||||
|
<div className={classes.historyList}>
|
||||||
|
{chats.map((chat) => (
|
||||||
|
<div
|
||||||
|
key={chat.id}
|
||||||
|
className={classes.historyItem}
|
||||||
|
data-active={chat.id === activeChatId || undefined}
|
||||||
|
onClick={() => onSelect(chat.id)}
|
||||||
|
>
|
||||||
|
<span className={classes.historyItemTitle}>
|
||||||
|
{chat.title || t("Untitled chat")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user