mirror of
https://github.com/docmost/docmost.git
synced 2026-05-11 08:54:06 +08:00
Compare commits
710 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+3
-2
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
dist
|
||||
data
|
||||
/data
|
||||
.env*
|
||||
.nx
|
||||
|
||||
+16
-2
@@ -2,7 +2,7 @@
|
||||
APP_URL=http://localhost: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
|
||||
|
||||
JWT_TOKEN_EXPIRES_IN=30d
|
||||
@@ -41,4 +41,18 @@ SMTP_IGNORETLS=false
|
||||
POSTMARK_TOKEN=
|
||||
|
||||
# for custom drawio server
|
||||
DRAWIO_URL=
|
||||
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.dev
|
||||
.env.prod
|
||||
data
|
||||
# compiled output
|
||||
/dist
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "apps/server/src/ee"]
|
||||
path = apps/server/src/ee
|
||||
url = https://github.com/docmost/ee
|
||||
+7
-5
@@ -1,19 +1,22 @@
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
|
||||
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
|
||||
|
||||
@@ -29,12 +32,11 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/pnpm*.yaml /app/
|
||||
COPY --from=builder /app/.npmrc /app/.npmrc
|
||||
|
||||
# Copy patches
|
||||
COPY --from=builder /app/patches /app/patches
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
Open-source collaborative wiki and documentation software.
|
||||
<br />
|
||||
<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>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
> [!NOTE]
|
||||
> Docmost is currently in **beta**. We value your feedback as we progress towards a stable release.
|
||||
|
||||
## 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
|
||||
|
||||
- Real-time collaboration
|
||||
- Diagrams (Draw.io, Excalidraw and Mermaid)
|
||||
- Spaces
|
||||
@@ -24,13 +24,39 @@ To get started with Docmost, please refer to our [documentation](https://docmost
|
||||
- Comments
|
||||
- Page history
|
||||
- Search
|
||||
- File attachment
|
||||
- File attachments
|
||||
- Embeds (Airtable, Loom, Miro and more)
|
||||
- Translations (10+ languages)
|
||||
|
||||
### Screenshots
|
||||
|
||||
#### Screenshots
|
||||
<p align="center">
|
||||
<img alt="home" src="https://docmost.com/screenshots/home.png" width="70%">
|
||||
<img alt="editor" src="https://docmost.com/screenshots/editor.png" width="70%">
|
||||
</p>
|
||||
|
||||
### Contributing
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+12
-3
@@ -2,10 +2,19 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.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 user-scalable=no" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+51
-43
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.6.2",
|
||||
"version": "0.80.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -10,67 +10,75 @@
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@casl/react": "^4.0.0",
|
||||
"@casl/react": "^5.0.1",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@mantine/core": "^7.14.2",
|
||||
"@mantine/form": "^7.14.2",
|
||||
"@mantine/hooks": "^7.14.2",
|
||||
"@mantine/modals": "^7.14.2",
|
||||
"@mantine/notifications": "^7.14.2",
|
||||
"@mantine/spotlight": "^7.14.2",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.61.4",
|
||||
"axios": "^1.7.8",
|
||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^8.3.18",
|
||||
"@mantine/form": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@mantine/spotlight": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "^5.16.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"jotai": "^2.10.3",
|
||||
"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",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.11",
|
||||
"lowlight": "^3.2.0",
|
||||
"mermaid": "^11.4.0",
|
||||
"katex": "0.16.40",
|
||||
"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-arborist": "^3.4.0",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.16",
|
||||
"zod": "^3.23.8"
|
||||
"react-drawio": "^1.0.7",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"semver": "^7.7.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.1",
|
||||
"@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/katex": "^0.16.7",
|
||||
"@types/node": "22.10.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/node": "22.19.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^15.13.0",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.0.0"
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+76
-47
@@ -10,53 +10,46 @@ import Groups from "@/pages/settings/group/groups";
|
||||
import GroupInfo from "./pages/settings/group/group-info";
|
||||
import Spaces from "@/pages/settings/space/spaces.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 SpaceHome from "@/pages/space/space-home.tsx";
|
||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||
import Layout from "@/components/layouts/global/layout.tsx";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
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() {
|
||||
const [, setSocket] = useAtom(socketAtom);
|
||||
const authToken = useAtomValue(authTokensAtom);
|
||||
|
||||
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();
|
||||
const { t } = useTranslation();
|
||||
useRedirectToCloudSelect();
|
||||
useTrackOrigin();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,25 +57,51 @@ export default function App() {
|
||||
<Route index element={<Navigate to="/home" />} />
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
<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 element={<Layout />}>
|
||||
<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/trash"} element={<SpaceTrash />} />
|
||||
<Route
|
||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||
element={
|
||||
<ErrorBoundary
|
||||
fallback={<>Failed to load page. An error occurred.</>}
|
||||
>
|
||||
<Page />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
element={<Page />}
|
||||
/>
|
||||
|
||||
<Route path={"/settings"}>
|
||||
@@ -91,11 +110,21 @@ export default function App() {
|
||||
path={"account/preferences"}
|
||||
element={<AccountPreferences />}
|
||||
/>
|
||||
<Route path={"account/api-keys"} element={<UserApiKeys />} />
|
||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
||||
<Route path={"groups"} element={<Groups />} />
|
||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -28,18 +29,27 @@ export default function ExportModal({
|
||||
}: ExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
|
||||
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 });
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
}
|
||||
setIncludeChildren(false);
|
||||
setIncludeAttachments(true);
|
||||
notifications.show({
|
||||
message: t("Export successful"),
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
@@ -47,6 +57,8 @@ export default function ExportModal({
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,17 +75,18 @@ export default function ExportModal({
|
||||
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}>Export {type}</Modal.Title>
|
||||
<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">Format</Text>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
</Group>
|
||||
@@ -84,7 +97,7 @@ export default function ExportModal({
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Include subpages</Text>
|
||||
<Text size="md">{t("Include subpages")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
@@ -93,6 +106,18 @@ export default function ExportModal({
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -102,7 +127,7 @@ export default function ExportModal({
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Include attachments</Text>
|
||||
<Text size="md">{t("Include attachments")}</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
@@ -116,9 +141,9 @@ export default function ExportModal({
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
Cancel
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport}>Export</Button>
|
||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
@@ -131,6 +156,8 @@ interface ExportFormatSelection {
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
@@ -143,7 +170,7 @@ function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
comboboxProps={{ width: "120" }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label="Select export format"
|
||||
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,81 +4,108 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import {Link} from 'react-router-dom';
|
||||
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
|
||||
import {buildPageUrl} from '@/features/page/page.utils.ts';
|
||||
import {formattedDate} from '@/lib/time.ts';
|
||||
import {useRecentChangesQuery} from '@/features/page/queries/page-query.ts';
|
||||
import {IconFileDescription} from '@tabler/icons-react';
|
||||
import {getSpaceUrl} from '@/lib/config.ts';
|
||||
ThemeIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export default function RecentChanges({spaceId}: Props) {
|
||||
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
||||
export default function RecentChanges({ spaceId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
|
||||
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton/>;
|
||||
return <PageListSkeleton />;
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.items.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon variant='transparent' color='gray' size={18}>
|
||||
<IconFileDescription size={18}/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || 'Untitled'}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
{!spaceId && (
|
||||
return pages.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="light"
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{cursor: 'pointer'}}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
<Group wrap="nowrap">
|
||||
{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>
|
||||
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{page?.space.name}
|
||||
</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">
|
||||
No pages yet
|
||||
</Text>
|
||||
<EmptyState
|
||||
icon={IconFiles}
|
||||
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,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,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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ActionIcon, rem } from "@mantine/core";
|
||||
import { ThemeIcon } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
export function IconGroupCircle() {
|
||||
return (
|
||||
<ActionIcon variant="light" size="lg" color="gray" radius="xl">
|
||||
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
|
||||
<IconUsersGroup stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</ThemeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,19 @@
|
||||
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 {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
@@ -16,6 +29,9 @@
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
import {Group, Text, Tooltip} from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import classes from "./app-header.module.css";
|
||||
import React from "react";
|
||||
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 {useAtom} from "jotai/index";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} 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 { 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() {
|
||||
const { t } = useTranslation();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
const [desktopOpened] = useAtom(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) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
{link.label}
|
||||
{t(link.label)}
|
||||
</Link>
|
||||
));
|
||||
|
||||
@@ -33,48 +64,118 @@ export function AppHeader() {
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group wrap="nowrap">
|
||||
{!isHomeRoute && (
|
||||
<>
|
||||
<Tooltip label="Sidebar toggle">
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<SidebarToggle
|
||||
aria-label="Sidebar toggle"
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Sidebar toggle">
|
||||
<SidebarToggle
|
||||
aria-label="Sidebar toggle"
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{cursor: "pointer", userSelect: "none"}}
|
||||
component={Link}
|
||||
to="/home"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
<Link to="/home" className={classes.brand} aria-label="Docmost">
|
||||
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||
<img
|
||||
src="/icons/favicon-32x32.png"
|
||||
alt="Docmost"
|
||||
width={22}
|
||||
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">
|
||||
{items}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group px={"xl"}>
|
||||
<TopMenu/>
|
||||
<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 />
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -27,5 +27,3 @@
|
||||
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 CommentList from "@/features/comment/components/comment-list.tsx";
|
||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
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() {
|
||||
const [{ tab }] = useAtom(asideStateAtom);
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
|
||||
let title: string;
|
||||
let component: ReactNode;
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentList />;
|
||||
component = <CommentListWithTabs />;
|
||||
title = "Comments";
|
||||
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:
|
||||
component = null;
|
||||
title = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component && (
|
||||
<>
|
||||
<Text mb="md" fw={500}>
|
||||
{title}
|
||||
</Text>
|
||||
{tab !== "chat" && (
|
||||
<Text mb="md" fw={500}>
|
||||
{t(title)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
{tab === "comments" || tab === "chat" ? (
|
||||
component
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom, sidebarWidthAtom,
|
||||
mobileSidebarAtom,
|
||||
sidebarWidthAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
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 Aside from "@/components/layouts/global/aside.tsx";
|
||||
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({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useTrialEndAction();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||
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);
|
||||
@@ -37,7 +46,9 @@ export default function GlobalAppShell({
|
||||
const resize = React.useCallback(
|
||||
(mouseMoveEvent) => {
|
||||
if (isResizing) {
|
||||
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
|
||||
const newWidth =
|
||||
mouseMoveEvent.clientX -
|
||||
sidebarRef.current.getBoundingClientRect().left;
|
||||
if (newWidth < 220) {
|
||||
setSidebarWidth(220);
|
||||
return;
|
||||
@@ -49,7 +60,7 @@ export default function GlobalAppShell({
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
},
|
||||
[isResizing]
|
||||
[isResizing],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,22 +76,21 @@ export default function GlobalAppShell({
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
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 showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!isHomeRoute && {
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}
|
||||
}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}}
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 350,
|
||||
@@ -93,23 +103,55 @@ export default function GlobalAppShell({
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!isHomeRoute && (
|
||||
<AppShell.Navbar className={classes.navbar} withBorder={false} ref={sidebarRef}>
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
aria-label={
|
||||
isSpaceRoute
|
||||
? 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 />}
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
<AppShell.Main>
|
||||
)}
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
{isAiRoute && <AiChatSidebar />}
|
||||
{showGlobalSidebar && <GlobalSidebar />}
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main id="main-content">
|
||||
{isSettingsRoute ? (
|
||||
<Container size={800}>{children}</Container>
|
||||
<Container size={900} pb={80}>
|
||||
{children}
|
||||
</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AppShell.Main>
|
||||
|
||||
{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 />
|
||||
</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);
|
||||
|
||||
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
||||
type AsideStateType = {
|
||||
tab: string;
|
||||
isAsideOpen: boolean;
|
||||
@@ -20,4 +21,4 @@ export const asideStateAtom = atom<AsideStateType>({
|
||||
isAsideOpen: false,
|
||||
});
|
||||
|
||||
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||
@@ -1,13 +1,23 @@
|
||||
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 { 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() {
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<GlobalAppShell>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
{isCloud() && <PosthogUser />}
|
||||
<SearchSpotlight spaceId={space?.id} />
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
|
||||
import {
|
||||
Group,
|
||||
Menu,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrightnessFilled,
|
||||
IconBrush,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconDeviceDesktop,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -13,10 +24,14 @@ import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { logout } = useAuth();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
@@ -31,27 +46,28 @@ export default function TopMenu() {
|
||||
<UnstyledButton>
|
||||
<Group gap={7} wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
avatarUrl={workspace.logo}
|
||||
name={workspace.name}
|
||||
avatarUrl={workspace?.logo}
|
||||
name={workspace?.name}
|
||||
variant="filled"
|
||||
size="sm"
|
||||
type={AvatarIconType.WORKSPACE_ICON}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||
{workspace.name}
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace?.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Workspace</Menu.Label>
|
||||
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
Workspace settings
|
||||
{t("Workspace settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
@@ -59,12 +75,12 @@ export default function TopMenu() {
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||
leftSection={<IconUsers size={16} />}
|
||||
>
|
||||
Manage members
|
||||
{t("Manage members")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Label>Account</Menu.Label>
|
||||
<Menu.Label>{t("Account")}</Menu.Label>
|
||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||
<Group wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
@@ -73,7 +89,7 @@ export default function TopMenu() {
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{width: 190}}>
|
||||
<div style={{ width: 190 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
@@ -88,7 +104,7 @@ export default function TopMenu() {
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||
leftSection={<IconUserCircle size={16} />}
|
||||
>
|
||||
My profile
|
||||
{t("My profile")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
@@ -96,13 +112,51 @@ export default function TopMenu() {
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||
leftSection={<IconBrush size={16} />}
|
||||
>
|
||||
My preferences
|
||||
{t("My preferences")}
|
||||
</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.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
Logout
|
||||
{t("Logout")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</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 { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
|
||||
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
@@ -8,20 +8,55 @@ import {
|
||||
IconUsersGroup,
|
||||
IconSpaces,
|
||||
IconBrush,
|
||||
IconCoin,
|
||||
IconLock,
|
||||
IconKey,
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
IconHistory,
|
||||
IconShieldCheck,
|
||||
} 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 { 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;
|
||||
icon: React.ElementType;
|
||||
path: string;
|
||||
}
|
||||
feature?: string;
|
||||
role?: "admin" | "owner";
|
||||
env?: "cloud" | "selfhosted";
|
||||
};
|
||||
|
||||
interface DataGroup {
|
||||
type DataGroup = {
|
||||
heading: string;
|
||||
items: DataItem[];
|
||||
}
|
||||
};
|
||||
|
||||
const groupedData: DataGroup[] = [
|
||||
{
|
||||
@@ -33,78 +68,252 @@ const groupedData: DataGroup[] = [
|
||||
icon: IconBrush,
|
||||
path: "/settings/account/preferences",
|
||||
},
|
||||
{
|
||||
label: "API keys",
|
||||
icon: IconKey,
|
||||
path: "/settings/account/api-keys",
|
||||
feature: Feature.API_KEYS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Workspace",
|
||||
items: [
|
||||
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
||||
{ label: "Members", icon: IconUsers, path: "/settings/members" },
|
||||
{
|
||||
label: "Members",
|
||||
icon: IconUsers,
|
||||
path: "/settings/members",
|
||||
label: "Billing",
|
||||
icon: IconCoin,
|
||||
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: "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() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
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(() => {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const menuItems = groupedData.map((group) => (
|
||||
<div key={group.heading}>
|
||||
<Text c="dimmed" className={classes.linkHeader}>
|
||||
{group.heading}
|
||||
</Text>
|
||||
{group.items.map((item) => (
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
const hasFeature = (f: string) =>
|
||||
entitlements?.features?.includes(f) ?? false;
|
||||
|
||||
const canShowItem = (item: DataItem) => {
|
||||
if (item.env === "cloud" && !isCloud()) return false;
|
||||
if (item.env === "selfhosted" && isCloud()) return false;
|
||||
if (item.role === "admin" && !isAdmin) return false;
|
||||
if (item.role === "owner" && !isOwner) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isItemDisabled = (item: DataItem) => {
|
||||
if (!item.feature) return false;
|
||||
return !hasFeature(item.feature);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={classes.navbar}>
|
||||
<Group className={classes.title} justify="flex-start">
|
||||
<ActionIcon
|
||||
onClick={() => navigate(-1)}
|
||||
onClick={() => {
|
||||
goBack();
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label="Back"
|
||||
aria-label={t("Back")}
|
||||
>
|
||||
<IconArrowLeft stroke={2} />
|
||||
</ActionIcon>
|
||||
<Text fw={500}>Settings</Text>
|
||||
<Text fw={500}>{t("Settings")}</Text>
|
||||
</Group>
|
||||
|
||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||
<div className={classes.version}>
|
||||
<Text
|
||||
className={classes.version}
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component="a"
|
||||
href="https://github.com/docmost/docmost/releases"
|
||||
target="_blank"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.version {
|
||||
.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() {
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
return (
|
||||
<Group justify="center" mt="xl">
|
||||
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
||||
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
||||
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Tooltip label="Toggle Color Scheme">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
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 { Avatar } from "@mantine/core";
|
||||
import { Avatar, MantineColor } from "@mantine/core";
|
||||
import { getAvatarUrl } from "@/lib/config.ts";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
interface CustomAvatarProps {
|
||||
avatarUrl: string;
|
||||
avatarUrl?: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: string | number;
|
||||
@@ -11,21 +12,57 @@ interface CustomAvatarProps {
|
||||
variant?: string;
|
||||
style?: 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<
|
||||
HTMLInputElement,
|
||||
CustomAvatarProps
|
||||
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl);
|
||||
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
const resolvedColor =
|
||||
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
ref={ref}
|
||||
src={avatarLink}
|
||||
name={name}
|
||||
name={initialsSource}
|
||||
alt={name}
|
||||
color="initials"
|
||||
color={resolvedColor}
|
||||
{...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,20 +1,25 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Popover,
|
||||
Button,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Picker = React.lazy(() => import('@emoji-mart/react'));
|
||||
} from "@mantine/core";
|
||||
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 {
|
||||
onEmojiSelect: (emoji: any) => void;
|
||||
icon: ReactNode;
|
||||
removeEmojiAction: () => void;
|
||||
readOnly: boolean;
|
||||
actionIconProps?: {
|
||||
size?: string;
|
||||
variant?: string;
|
||||
c?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function EmojiPicker({
|
||||
@@ -22,9 +27,28 @@ function EmojiPicker({
|
||||
icon,
|
||||
removeEmojiAction,
|
||||
readOnly,
|
||||
actionIconProps,
|
||||
}: EmojiPickerInterface) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
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) => {
|
||||
onEmojiSelect(emoji);
|
||||
@@ -43,37 +67,46 @@ function EmojiPicker({
|
||||
width={332}
|
||||
position="bottom"
|
||||
disabled={readOnly}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
|
||||
<Popover.Target ref={setTarget}>
|
||||
<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}
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown bg="000" style={{ border: 'none' }}>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={null}>
|
||||
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
|
||||
<Picker
|
||||
data={async () => (await import('@emoji-mart/data')).default}
|
||||
data={async () => (await import("@emoji-mart/data")).default}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
skinTonePosition="search"
|
||||
theme={colorScheme}
|
||||
/>
|
||||
</Suspense>
|
||||
<Button
|
||||
variant="default"
|
||||
c="gray"
|
||||
size="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
bottom: '1rem',
|
||||
right: '1rem',
|
||||
}}
|
||||
onClick={handleRemoveEmoji}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Popover.Dropdown>
|
||||
<Button
|
||||
variant="default"
|
||||
c="gray"
|
||||
size="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
}}
|
||||
onClick={handleRemoveEmoji}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
</Popover.Dropdown>
|
||||
</Suspense>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -2,21 +2,24 @@ import { Title, Text, Button, Container, Group } from "@mantine/core";
|
||||
import classes from "./error-404.module.css";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function Error404() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>404 page not found - Docmost</title>
|
||||
<title>{t("404 page not found")} - Docmost</title>
|
||||
</Helmet>
|
||||
<Container className={classes.root}>
|
||||
<Title className={classes.title}>404 Page Not Found</Title>
|
||||
<Title className={classes.title}>{t("404 page not found")}</Title>
|
||||
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
|
||||
Sorry, we can't find the page you are looking for.
|
||||
{t("Sorry, we can't find the page you are looking for.")}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button component={Link} to={"/home"} variant="subtle" size="md">
|
||||
Take me back to homepage
|
||||
{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 { Group, Text, Menu, Button } from "@mantine/core";
|
||||
import { IRoleData } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
name: string;
|
||||
@@ -36,10 +37,12 @@ export default function RoleSelectMenu({
|
||||
onChange,
|
||||
disabled,
|
||||
}: RoleMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu.Target>
|
||||
<RoleButton name={roleName} disabled={disabled} />
|
||||
<RoleButton name={t(roleName)} disabled={disabled} />
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
@@ -50,9 +53,9 @@ export default function RoleSelectMenu({
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
<Text size="sm">{item.label}</Text>
|
||||
<Text size="sm">{t(item.label)}</Text>
|
||||
<Text size="xs" opacity={0.65}>
|
||||
{item.description}
|
||||
{t(item.description)}
|
||||
</Text>
|
||||
</div>
|
||||
{item.label === roleName && <IconCheck size={20} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconPlus,
|
||||
IconChevronDown,
|
||||
IconArrowsDiagonal,
|
||||
IconX,
|
||||
IconSparkles,
|
||||
IconFileText,
|
||||
IconLanguage,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useChatStream } from "../hooks/use-chat-stream";
|
||||
import { useChatInfoQuery } from "../queries/ai-chat-query";
|
||||
import ChatMessageList from "./chat-message-list";
|
||||
import ChatInput from "./chat-input";
|
||||
import AsideChatHistory from "./aside-chat-history";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/aside-chat-panel.module.css";
|
||||
|
||||
type QuickAction = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export default function AsideChatPanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [chatId, setChatId] = useState<string | undefined>(undefined);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [contextPages, setContextPages] = useState<PageMention[]>([]);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
|
||||
const chatInfoQuery = useChatInfoQuery(chatId);
|
||||
const {
|
||||
messages,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
hydrateFromServer,
|
||||
} = useChatStream(chatId, {
|
||||
onChatCreated: (newChatId) => {
|
||||
setChatId(newChatId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (page && !chatId) {
|
||||
setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]);
|
||||
}
|
||||
}, [page, chatId]);
|
||||
|
||||
const handleRemoveContextPage = useCallback((pageId: string) => {
|
||||
setContextPages((prev) => prev.filter((p) => p.id !== pageId));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatInfoQuery.data?.messages) {
|
||||
hydrateFromServer(chatInfoQuery.data.messages);
|
||||
}
|
||||
}, [chatInfoQuery.data, hydrateFromServer]);
|
||||
|
||||
// Drop the open chatId if the current user lost access to it (404/403 on
|
||||
// the info fetch). Reverts the panel to a fresh chat instead of presenting
|
||||
// an input tied to a chat the user does not own.
|
||||
useEffect(() => {
|
||||
if (chatId && chatInfoQuery.isError) {
|
||||
setChatId(undefined);
|
||||
}
|
||||
}, [chatId, chatInfoQuery.isError]);
|
||||
|
||||
const handleNewChat = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setChatId(undefined);
|
||||
if (page) {
|
||||
setContextPages([
|
||||
{ id: page.id, title: page.title || "", slugId: page.slugId },
|
||||
]);
|
||||
}
|
||||
},
|
||||
[page],
|
||||
);
|
||||
|
||||
const handleSelectChat = useCallback((selectedChatId: string) => {
|
||||
setChatId(selectedChatId);
|
||||
setHistoryOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (chatId) {
|
||||
navigate(`/ai/chat/${chatId}`);
|
||||
} else {
|
||||
navigate("/ai");
|
||||
}
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}, [chatId, navigate, setAsideState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}, [setAsideState]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(content: string, mentions: PageMention[], attachments: ChatAttachment[]) => {
|
||||
const contextPageId = contextPages.length > 0 ? contextPages[0].id : undefined;
|
||||
sendMessage(content, mentions, attachments, contextPageId);
|
||||
},
|
||||
[sendMessage, contextPages],
|
||||
);
|
||||
|
||||
const handleQuickAction = useCallback(
|
||||
(prompt: string) => {
|
||||
handleSend(prompt, [], []);
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{ icon: <IconFileText size={16} />, label: t("Summarize this page"), prompt: "Summarize this page" },
|
||||
{ icon: <IconLanguage size={16} />, label: t("Translate this page"), prompt: "Translate this page" },
|
||||
{ icon: <IconSearch size={16} />, label: t("Analyze for insights"), prompt: "Analyze this page for insights" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classes.panel}>
|
||||
<div className={classes.toolbar}>
|
||||
<Popover
|
||||
opened={historyOpen}
|
||||
onChange={setHistoryOpen}
|
||||
position="bottom-start"
|
||||
width={280}
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
className={classes.titleButton}
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
>
|
||||
<span className={classes.titleText}>
|
||||
{chatInfoQuery.data?.chat?.title || t("New chat")}
|
||||
</span>
|
||||
<IconChevronDown size={16} stroke={1.75} />
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<AsideChatHistory activeChatId={chatId} onSelect={handleSelectChat} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<div className={classes.toolbarSpacer} />
|
||||
|
||||
<Tooltip label={t("New chat")} openDelay={250}>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href="/ai"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("New chat")}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<IconPlus size={20} stroke={1.75} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Open full page")} openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Open full page")}
|
||||
onClick={handleExpand}
|
||||
>
|
||||
<IconArrowsDiagonal size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Close")} openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Close")}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<IconX size={20} stroke={1.75} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--mantine-spacing-xs) var(--mantine-spacing-sm)",
|
||||
color: "var(--mantine-color-red-6)",
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMessages ? (
|
||||
<>
|
||||
<div className={classes.messages} data-aside-chat>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={classes.emptyState}>
|
||||
<IconSparkles size={36} stroke={1.5} className={classes.emptyStateIcon} />
|
||||
<div className={classes.emptyStateTitle}>{t("How can I help you today?")}</div>
|
||||
<div className={classes.quickActions}>
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
className={classes.quickAction}
|
||||
onClick={() => handleQuickAction(action.prompt)}
|
||||
>
|
||||
<span className={classes.quickActionIcon}>{action.icon}</span>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.inputArea}>
|
||||
<ChatInput
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
onStop={stopGeneration}
|
||||
placeholder={t("Ask anything...")}
|
||||
autofocus={false}
|
||||
contextPages={contextPages}
|
||||
onRemoveContextPage={handleRemoveContextPage}
|
||||
variant="flat"
|
||||
chatId={chatId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
IconSparkles,
|
||||
IconSearch,
|
||||
IconFilePlus,
|
||||
IconEdit,
|
||||
IconFileText,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ChatInput from "./chat-input";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
type Suggestion = {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
const SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
icon: <IconSearch size={16} />,
|
||||
text: "Search across all pages",
|
||||
prompt: "Search for pages about ",
|
||||
},
|
||||
{
|
||||
icon: <IconFilePlus size={16} />,
|
||||
text: "Create a new page",
|
||||
prompt: "Create a new page titled ",
|
||||
},
|
||||
{
|
||||
icon: <IconFileText size={16} />,
|
||||
text: "Summarize a page",
|
||||
prompt: "Summarize the page @",
|
||||
},
|
||||
{
|
||||
icon: <IconEdit size={16} />,
|
||||
text: "Update page content",
|
||||
prompt: "Update the page @",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
isStreaming: boolean;
|
||||
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSuggestionClick = (prompt: string) => {
|
||||
onSend(prompt, [], []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
|
||||
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
|
||||
<div className={classes.emptyStateTitle}>
|
||||
{t("What can I help you with?")}
|
||||
</div>
|
||||
|
||||
<div className={classes.emptyStateInput}>
|
||||
<ChatInput
|
||||
isStreaming={isStreaming}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
placeholder={t("Ask anything... Use @ to mention pages")}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={classes.suggestionsSection}>
|
||||
<div className={classes.suggestionsLabel}>Get started</div>
|
||||
<div className={classes.suggestionsGrid}>
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.text}
|
||||
type="button"
|
||||
className={classes.suggestionCard}
|
||||
onClick={() => handleSuggestionClick(s.prompt)}
|
||||
>
|
||||
<span className={classes.suggestionIcon}>{s.icon}</span>
|
||||
<span className={classes.suggestionText}>{s.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { CharacterCount } from "@tiptap/extensions";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
||||
import { uploadChatFile } from "../services/ai-chat-service";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-input.module.css";
|
||||
|
||||
type PendingAttachment = ChatAttachment & { uploading: boolean };
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
|
||||
const ACCEPTED_FILE_TYPES = ".pdf,.docx,.txt,.csv,.md,.png,.jpg,.jpeg,.webp";
|
||||
// Kept in sync with MAX_ATTACHMENTS_PER_MESSAGE in apps/server/src/ee/ai-chat/ai-chat-limits.ts
|
||||
const MAX_ATTACHMENTS_PER_MESSAGE = 5;
|
||||
|
||||
type Props = {
|
||||
isStreaming: boolean;
|
||||
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
|
||||
onStop: () => void;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
contextPages?: PageMention[];
|
||||
onRemoveContextPage?: (pageId: string) => void;
|
||||
variant?: "card" | "flat";
|
||||
showDisclaimer?: boolean;
|
||||
chatId?: string;
|
||||
};
|
||||
|
||||
function extractMentions(json: any): PageMention[] {
|
||||
const mentions: PageMention[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function walk(node: any) {
|
||||
if (node.type === "mention" && node.attrs?.entityType === "page" && node.attrs?.entityId) {
|
||||
if (!seen.has(node.attrs.entityId)) {
|
||||
seen.add(node.attrs.entityId);
|
||||
mentions.push({
|
||||
id: node.attrs.entityId,
|
||||
title: node.attrs.label || "",
|
||||
slugId: node.attrs.slugId || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(json);
|
||||
return mentions;
|
||||
}
|
||||
|
||||
function editorJsonToText(json: any): string {
|
||||
let text = "";
|
||||
|
||||
function walk(node: any) {
|
||||
if (node.type === "text") {
|
||||
text += node.text || "";
|
||||
} else if (node.type === "mention") {
|
||||
text += `@${node.attrs?.label || ""}`;
|
||||
} else if (node.type === "paragraph") {
|
||||
if (text.length > 0) text += "\n";
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(json);
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
isStreaming,
|
||||
onSend,
|
||||
onStop,
|
||||
placeholder,
|
||||
autofocus = true,
|
||||
contextPages,
|
||||
onRemoveContextPage,
|
||||
variant = "card",
|
||||
showDisclaimer = true,
|
||||
chatId,
|
||||
}: Props) {
|
||||
const chatIdRef = useRef(chatId);
|
||||
chatIdRef.current = chatId;
|
||||
const { t } = useTranslation();
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const onSendRef = useRef(onSend);
|
||||
onSendRef.current = onSend;
|
||||
|
||||
const handleFileSelect = useCallback(async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
|
||||
const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length;
|
||||
if (room <= 0) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
message: t("You can attach up to {{max}} files per message.", {
|
||||
max: MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
}),
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = Array.from(files);
|
||||
const accepted = incoming.slice(0, room);
|
||||
|
||||
if (incoming.length > accepted.length) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
message: t(
|
||||
"Only the first {{n}} file(s) were added (max {{max}} per message).",
|
||||
{ n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of accepted) {
|
||||
const tempId = `uploading-${Date.now()}-${Math.random()}`;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
const placeholder: PendingAttachment = {
|
||||
id: tempId,
|
||||
fileName: file.name,
|
||||
fileExt: ext,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
uploading: true,
|
||||
};
|
||||
|
||||
setPendingAttachments((prev) => [...prev, placeholder]);
|
||||
|
||||
try {
|
||||
const uploaded = await uploadChatFile(file, chatIdRef.current);
|
||||
setPendingAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === tempId ? { ...uploaded, uploading: false } : a,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== tempId));
|
||||
}
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [pendingAttachments.length, t]);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!editor || isStreaming) return;
|
||||
const json = editor.getJSON();
|
||||
const text = editorJsonToText(json).trim();
|
||||
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
|
||||
if (!text && readyAttachments.length === 0) return;
|
||||
|
||||
const mentions = extractMentions(json);
|
||||
onSendRef.current(text, mentions, readyAttachments);
|
||||
editor.commands.clearContent();
|
||||
editor.commands.focus();
|
||||
setPendingAttachments([]);
|
||||
}, [isStreaming, pendingAttachments]);
|
||||
|
||||
const handleSubmitRef = useRef(handleSubmit);
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
gapcursor: false,
|
||||
dropcursor: false,
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || t("Ask anything... Use @ to mention pages"),
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: 50000,
|
||||
}),
|
||||
LinkExtension,
|
||||
EmojiCommand,
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
allowSpaces: true,
|
||||
items: () => [],
|
||||
// @ts-ignore
|
||||
render: mentionRenderItems,
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
|
||||
"aria-multiline": "true",
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
|
||||
event.key,
|
||||
)
|
||||
) {
|
||||
const emojiCommand = document.querySelector("#emoji-command");
|
||||
const mentionPopup = document.querySelector("#mention");
|
||||
if (emojiCommand || mentionPopup) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmitRef.current();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
content: "",
|
||||
editable: true,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: autofocus ? "end" : false,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
setIsEmpty(!e.getText().trim());
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && autofocus) {
|
||||
editor.commands.focus();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
|
||||
|
||||
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={wrapperClass} data-chat-input>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_FILE_TYPES}
|
||||
multiple
|
||||
aria-label={t("Add files")}
|
||||
tabIndex={-1}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
|
||||
{((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
|
||||
<div className={classes.attachmentChips}>
|
||||
{contextPages?.map((page) => (
|
||||
<div key={page.id} className={classes.attachmentChip}>
|
||||
<IconFileText size={14} />
|
||||
<span className={classes.attachmentChipName}>
|
||||
{page.title || "Untitled"}
|
||||
</span>
|
||||
{onRemoveContextPage && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.attachmentChipRemove}
|
||||
onClick={() => onRemoveContextPage(page.id)}
|
||||
aria-label={`Remove ${page.title}`}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pendingAttachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={`${classes.attachmentChip} ${attachment.uploading ? classes.attachmentChipUploading : ""}`}
|
||||
>
|
||||
{IMAGE_EXTENSIONS.includes(attachment.fileExt) ? (
|
||||
<IconPhoto size={14} />
|
||||
) : (
|
||||
<IconFile size={14} />
|
||||
)}
|
||||
<span className={classes.attachmentChipName}>
|
||||
{attachment.fileName}
|
||||
</span>
|
||||
{!attachment.uploading && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.attachmentChipRemove}
|
||||
onClick={() => removeAttachment(attachment.id)}
|
||||
aria-label={`Remove ${attachment.fileName}`}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditorContent editor={editor} className={classes.editorContent} />
|
||||
<div className={classes.actions}>
|
||||
<Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.plusButton}
|
||||
onClick={() => setPlusMenuOpen((o) => !o)}
|
||||
aria-label="Add content"
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
disabled={pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE}
|
||||
title={
|
||||
pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE
|
||||
? t("Max {{max}} files per message", {
|
||||
max: MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<IconPaperclip size={16} className={classes.plusMenuIcon} />
|
||||
{t("Add files")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
editor?.commands.insertContent("@");
|
||||
editor?.commands.focus();
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
||||
Mention a page
|
||||
</button>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.stopButton}
|
||||
onClick={onStop}
|
||||
aria-label="Stop generation"
|
||||
>
|
||||
<IconPlayerStopFilled size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.sendButton}
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasContent}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<IconArrowUp size={16} stroke={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showDisclaimer && (
|
||||
<div className={classes.disclaimer}>
|
||||
{t("AI-generated content may not be accurate.")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatMessage from "./chat-message";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
function ChatMessageErrorFallback() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={classes.messageErrorFallback}>
|
||||
<IconAlertTriangle size={14} />
|
||||
<span>{t("Failed to render this message.")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
messages: AiChatMessage[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
streamingToolCalls: AiChatToolCall[];
|
||||
};
|
||||
|
||||
const BOTTOM_THRESHOLD_PX = 32;
|
||||
const SCROLL_UP_THRESHOLD_PX = 5;
|
||||
const SMOOTH_SCROLL_SETTLE_MS = 600;
|
||||
|
||||
export default function ChatMessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const isAutoScrollingRef = useRef(false);
|
||||
const prevScrollTopRef = useRef(0);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
isAutoScrollingRef.current = true;
|
||||
const target = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTo({ top: target, behavior });
|
||||
prevScrollTopRef.current = target;
|
||||
isAtBottomRef.current = true;
|
||||
setShowScrollButton(false);
|
||||
|
||||
if (behavior === "smooth") {
|
||||
setTimeout(() => {
|
||||
isAutoScrollingRef.current = false;
|
||||
if (containerRef.current) {
|
||||
prevScrollTopRef.current = containerRef.current.scrollTop;
|
||||
}
|
||||
}, SMOOTH_SCROLL_SETTLE_MS);
|
||||
} else {
|
||||
isAutoScrollingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (isAutoScrollingRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const currentScrollTop = container.scrollTop;
|
||||
const scrolledUp =
|
||||
currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX;
|
||||
prevScrollTopRef.current = currentScrollTop;
|
||||
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - currentScrollTop - container.clientHeight;
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
|
||||
if (scrolledUp) {
|
||||
isAtBottomRef.current = atBottom;
|
||||
} else if (atBottom) {
|
||||
isAtBottomRef.current = true;
|
||||
}
|
||||
|
||||
setShowScrollButton(!atBottom);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
// Instant scroll during streaming to keep up with rapid updates
|
||||
useEffect(() => {
|
||||
if (isAtBottomRef.current) {
|
||||
scrollToBottom("instant");
|
||||
}
|
||||
}, [streamingContent, streamingToolCalls.length, scrollToBottom]);
|
||||
|
||||
// Smooth scroll for new messages. Always force-scroll when the latest
|
||||
// message is from the user (they just sent it), even if they were reading
|
||||
// scrollback.
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastIsUser = lastMessage?.role === "user";
|
||||
if (lastIsUser || isAtBottomRef.current) {
|
||||
scrollToBottom("smooth");
|
||||
return;
|
||||
}
|
||||
|
||||
// No auto-scroll: recompute from actual layout so that chat switches to
|
||||
// content that doesn't overflow correctly hide the button even when no
|
||||
// scroll event fires.
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setShowScrollButton(!atBottom);
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
return (
|
||||
<div className={classes.messageListWrapper}>
|
||||
<div ref={containerRef} className={classes.messageList}>
|
||||
{messages.map((msg) => (
|
||||
<ErrorBoundary
|
||||
key={msg.id}
|
||||
fallback={<ChatMessageErrorFallback />}
|
||||
>
|
||||
<ChatMessage message={msg} />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<ErrorBoundary
|
||||
resetKeys={[streamingContent, streamingToolCalls.length]}
|
||||
fallback={<ChatMessageErrorFallback />}
|
||||
>
|
||||
<ChatMessage
|
||||
message={{
|
||||
id: "streaming",
|
||||
chatId: "",
|
||||
role: "assistant",
|
||||
content: null,
|
||||
toolCalls: null,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}}
|
||||
isStreaming
|
||||
streamingContent={streamingContent}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{showScrollButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll to bottom"
|
||||
className={classes.scrollToBottomButton}
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
>
|
||||
<IconArrowDown size={16} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import DOMPurify from "dompurify";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconFile,
|
||||
IconLoader2,
|
||||
IconPhoto,
|
||||
} from "@tabler/icons-react";
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatToolGroup from "./chat-tool-group";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
|
||||
const chatSanitizer = DOMPurify();
|
||||
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
const href = node.getAttribute("href") || "";
|
||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
|
||||
|
||||
type Props = {
|
||||
message: AiChatMessage;
|
||||
isStreaming?: boolean;
|
||||
streamingContent?: string;
|
||||
streamingToolCalls?: AiChatToolCall[];
|
||||
};
|
||||
|
||||
export default function ChatMessage({
|
||||
message,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleContentClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest("a");
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute("href");
|
||||
if (href && (href.startsWith("/s/") || href.startsWith("/p/"))) {
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
if (message.role === "tool") return null;
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const content = isStreaming ? streamingContent : message.content;
|
||||
const toolCalls = isStreaming ? streamingToolCalls : message.toolCalls;
|
||||
|
||||
if (isUser) {
|
||||
const displayContent = (content || "").replace(
|
||||
/\n\n<referenced_pages>[\s\S]*<\/referenced_pages>$/,
|
||||
"",
|
||||
);
|
||||
const attachments =
|
||||
(message.metadata?.attachments as {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileExt: string;
|
||||
}[]) || [];
|
||||
|
||||
return (
|
||||
<div className={classes.userMessage}>
|
||||
<div className={classes.userBubble}>
|
||||
{attachments.length > 0 && (
|
||||
<div className={classes.messageAttachments}>
|
||||
{attachments.map((a) => (
|
||||
<span key={a.id} className={classes.messageAttachmentChip}>
|
||||
{IMAGE_EXTENSIONS.includes(a.fileExt) ? (
|
||||
<IconPhoto size={13} />
|
||||
) : (
|
||||
<IconFile size={13} />
|
||||
)}
|
||||
{a.fileName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{displayContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.assistantMessage}>
|
||||
<div className={classes.messageContent}>
|
||||
{toolCalls && toolCalls.length > 0 && (
|
||||
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
|
||||
)}
|
||||
{content && (
|
||||
<div
|
||||
onClick={handleContentClick}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: chatSanitizer.sanitize(
|
||||
markdownToHtml(content) as string,
|
||||
{ ADD_ATTR: ["target", "rel"] },
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<>
|
||||
{!content && (
|
||||
<span className={classes.processingIndicator}>
|
||||
<IconLoader2 size={16} className={classes.processingSpinner} />
|
||||
Thinking
|
||||
</span>
|
||||
)}
|
||||
<span className={classes.streamingCursor} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isStreaming && message.content && (
|
||||
<div className={classes.messageActions}>
|
||||
<CopyTextButton text={message?.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconChevronDown,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
import type { AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatToolResult, { TOOL_LABELS } from "./chat-tool-result";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
|
||||
type Props = {
|
||||
toolCalls: AiChatToolCall[];
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
const activeCall =
|
||||
isStreaming && toolCalls.length > 0
|
||||
? [...toolCalls].reverse().find((tc) => tc.result === undefined)
|
||||
: undefined;
|
||||
|
||||
const activeLabel = activeCall
|
||||
? TOOL_LABELS[activeCall.name] || activeCall.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={classes.toolGroup}>
|
||||
<div
|
||||
className={classes.toolGroupHeader}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setExpanded((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeLabel ? (
|
||||
<IconLoader2 size={12} className={classes.processingSpinner} />
|
||||
) : expanded ? (
|
||||
<IconChevronDown size={12} />
|
||||
) : (
|
||||
<IconChevronRight size={12} />
|
||||
)}
|
||||
<span className={classes.toolGroupLabel}>
|
||||
{activeLabel ? `${activeLabel}…` : `Steps ${toolCalls.length}`}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={classes.toolGroupSteps}>
|
||||
{toolCalls.map((tc) => (
|
||||
<ChatToolResult key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { IconChevronRight, IconChevronDown } from "@tabler/icons-react";
|
||||
import type { AiChatToolCall } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
|
||||
export const TOOL_LABELS: Record<string, string> = {
|
||||
list_spaces: "Listed spaces",
|
||||
search_pages: "Searched pages",
|
||||
get_page: "Read page",
|
||||
create_page: "Created page",
|
||||
update_page: "Updated page",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
toolCall: AiChatToolCall;
|
||||
};
|
||||
|
||||
export default function ChatToolResult({ toolCall }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const label = TOOL_LABELS[toolCall.name] || toolCall.name;
|
||||
|
||||
return (
|
||||
<div className={classes.toolStep}>
|
||||
<div
|
||||
className={classes.toolStepRow}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
<span className={classes.toolStepBullet}>·</span>
|
||||
{expanded ? (
|
||||
<IconChevronDown size={12} />
|
||||
) : (
|
||||
<IconChevronRight size={12} />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={classes.toolStepDetails}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(
|
||||
{ args: toolCall.args, result: toolCall.result },
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
export default function EnableAiChat() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="md">{t("AI Chat")}</Text>
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
{t("Beta")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<AiChatToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function AiChatToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.chat);
|
||||
const hasAccess = useHasFeature(Feature.AI);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ aiChat: value } as any);
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err: any) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle AI Chat")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { sendChatMessage } from "../services/ai-chat-service";
|
||||
import type {
|
||||
AiChatMessage,
|
||||
AiChatStreamEvent,
|
||||
AiChatToolCall,
|
||||
ChatAttachment,
|
||||
PageMention,
|
||||
} from "../types/ai-chat.types";
|
||||
|
||||
type ChatStreamOptions = {
|
||||
onChatCreated?: (chatId: string) => void;
|
||||
};
|
||||
|
||||
export function useChatStream(
|
||||
chatId: string | undefined,
|
||||
options?: ChatStreamOptions,
|
||||
) {
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([]);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [streamingToolCalls, setStreamingToolCalls] = useState<AiChatToolCall[]>(
|
||||
[],
|
||||
);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorCode, setErrorCode] = useState<string | null>(null);
|
||||
const [isRetryable, setIsRetryable] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const currentChatIdRef = useRef(chatId);
|
||||
currentChatIdRef.current = chatId;
|
||||
// Tracks which chatId the local `messages` state currently represents.
|
||||
// Set when we seed from a server fetch AND when we optimistically own a
|
||||
// freshly-created chat after `chat_created`. This is the single authority
|
||||
// marker that keeps server-state effects from clobbering in-flight streams.
|
||||
const hydratedChatIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Reset local state when the consumer switches to a different chat.
|
||||
// Skip the reset if the new chatId is one the hook itself already claimed
|
||||
// during a new-chat flow — in that case our optimistic state is the truth.
|
||||
useEffect(() => {
|
||||
if (chatId && chatId === hydratedChatIdRef.current) return;
|
||||
hydratedChatIdRef.current = undefined;
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setErrorCode(null);
|
||||
setIsRetryable(false);
|
||||
}, [chatId]);
|
||||
|
||||
const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => {
|
||||
const forId = currentChatIdRef.current;
|
||||
if (!forId) return;
|
||||
if (hydratedChatIdRef.current === forId) return;
|
||||
hydratedChatIdRef.current = forId;
|
||||
setMessages(msgs);
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => {
|
||||
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
|
||||
|
||||
setError(null);
|
||||
setErrorCode(null);
|
||||
setIsRetryable(false);
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
setStreamingToolCalls([]);
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (mentions.length) {
|
||||
metadata.mentionedPageIds = mentions.map((m) => m.id);
|
||||
}
|
||||
if (attachments.length) {
|
||||
metadata.attachments = attachments.map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
fileExt: a.fileExt,
|
||||
}));
|
||||
}
|
||||
|
||||
const userMessage: AiChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "user",
|
||||
content,
|
||||
toolCalls: null,
|
||||
metadata: Object.keys(metadata).length ? metadata : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
const attachmentIds = attachments.map((a) => a.id);
|
||||
|
||||
const abortController = sendChatMessage(
|
||||
{
|
||||
chatId: currentChatIdRef.current,
|
||||
content,
|
||||
mentionedPageIds: mentions.map((m) => m.id),
|
||||
...(contextPageId && { contextPageId }),
|
||||
...(attachmentIds.length && { attachmentIds }),
|
||||
},
|
||||
(event: AiChatStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case "chat_created":
|
||||
currentChatIdRef.current = event.chatId;
|
||||
// Claim authority over this new chatId so when the consumer's
|
||||
// prop catches up via navigation/onChatCreated, the reset effect
|
||||
// sees a match and preserves our optimistic messages.
|
||||
hydratedChatIdRef.current = event.chatId;
|
||||
if (options?.onChatCreated) {
|
||||
options.onChatCreated(event.chatId);
|
||||
} else {
|
||||
navigate(`/ai/chat/${event.chatId}`, { replace: true });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
break;
|
||||
case "content":
|
||||
setStreamingContent((prev) => prev + event.text);
|
||||
break;
|
||||
case "tool_call":
|
||||
setStreamingToolCalls((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
case "tool_result":
|
||||
setStreamingToolCalls((prev) =>
|
||||
prev.map((tc) =>
|
||||
tc.id === event.id ? { ...tc, result: event.result } : tc,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "done": {
|
||||
setStreamingContent((currentContent) => {
|
||||
setStreamingToolCalls((currentToolCalls) => {
|
||||
const assistantMessage: AiChatMessage = {
|
||||
id: event.messageId,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "assistant",
|
||||
content: currentContent || null,
|
||||
toolCalls: currentToolCalls.length
|
||||
? currentToolCalls
|
||||
: null,
|
||||
metadata: event.usage ? { tokenUsage: event.usage } : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
return [];
|
||||
});
|
||||
return "";
|
||||
});
|
||||
setIsStreaming(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["ai-chat", currentChatIdRef.current],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
setError(event.message);
|
||||
setErrorCode(event.code || null);
|
||||
setIsRetryable(event.retryable || false);
|
||||
setIsStreaming(false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
(errorMsg) => {
|
||||
setError(errorMsg);
|
||||
setIsStreaming(false);
|
||||
},
|
||||
() => {
|
||||
setIsStreaming(false);
|
||||
},
|
||||
);
|
||||
|
||||
abortRef.current = abortController;
|
||||
},
|
||||
[isStreaming, navigate, queryClient],
|
||||
);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
|
||||
setStreamingContent((currentContent) => {
|
||||
setStreamingToolCalls((currentToolCalls) => {
|
||||
if (currentContent || currentToolCalls.length > 0) {
|
||||
const partialMessage: AiChatMessage = {
|
||||
id: `stopped-${Date.now()}`,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "assistant",
|
||||
content: currentContent || null,
|
||||
toolCalls: currentToolCalls.length ? currentToolCalls : null,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, partialMessage]);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
return "";
|
||||
});
|
||||
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
isStreaming,
|
||||
error,
|
||||
errorCode,
|
||||
isRetryable,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
hydrateFromServer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AiChatLayout from "../components/ai-chat-layout";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
export default function AiChat() {
|
||||
const { t } = useTranslation();
|
||||
const { chatId } = useParams<{ chatId: string }>();
|
||||
|
||||
return (
|
||||
<div className={classes.layout}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[chatId]}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<EmptyState
|
||||
icon={IconAlertTriangle}
|
||||
title={t("Failed to load chat. An error occurred.")}
|
||||
action={
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
mt="xs"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
{t("Try again")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<AiChatLayout />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
listChats,
|
||||
getChatInfo,
|
||||
deleteChat,
|
||||
updateChatTitle,
|
||||
searchChats,
|
||||
} from "../services/ai-chat-service";
|
||||
|
||||
export function useChatsQuery() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["ai-chats"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listChats({ cursor: pageParam, limit: 30 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function useChatInfoQuery(chatId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["ai-chat", chatId],
|
||||
queryFn: () => getChatInfo(chatId!),
|
||||
enabled: !!chatId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteChatMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (chatId: string) => deleteChat(chatId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateChatTitleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ chatId, title }: { chatId: string; title: string }) =>
|
||||
updateChatTitle(chatId, title),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchChatsQuery(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["ai-chats-search", query],
|
||||
queryFn: () => searchChats(query),
|
||||
enabled: query.length > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import api from "@/lib/api-client.ts";
|
||||
import type {
|
||||
AiChat,
|
||||
AiChatMessage,
|
||||
AiChatStreamEvent,
|
||||
ChatAttachment,
|
||||
} from "../types/ai-chat.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function createChat(): Promise<AiChat> {
|
||||
const req = await api.post<AiChat>("/ai/chats/create");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function listChats(params?: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<IPagination<AiChat>> {
|
||||
const req = await api.post("/ai/chats", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getChatInfo(
|
||||
chatId: string,
|
||||
): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
|
||||
const req = await api.post("/ai/chats/info", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai/chats/delete", { chatId });
|
||||
}
|
||||
|
||||
export async function updateChatTitle(
|
||||
chatId: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
await api.post("/ai/chats/update", { chatId, title });
|
||||
}
|
||||
|
||||
export async function searchChats(query: string): Promise<AiChat[]> {
|
||||
const req = await api.post("/ai/chats/search", { query });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadChatFile(
|
||||
file: File,
|
||||
chatId?: string,
|
||||
): Promise<ChatAttachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (chatId) {
|
||||
formData.append("chatId", chatId);
|
||||
}
|
||||
return await api.post("/ai/chats/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
}
|
||||
|
||||
export function sendChatMessage(
|
||||
params: {
|
||||
chatId?: string;
|
||||
content: string;
|
||||
mentionedPageIds?: string[];
|
||||
contextPageId?: string;
|
||||
attachmentIds?: string[];
|
||||
},
|
||||
onEvent: (event: AiChatStreamEvent) => void,
|
||||
onError?: (error: string) => void,
|
||||
onComplete?: () => void,
|
||||
): AbortController {
|
||||
const abortController = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/ai/chats/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
signal: abortController.signal,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
let errorMessage = `HTTP error ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody);
|
||||
errorMessage = parsed.message || errorMessage;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
onError?.("Response body is not readable");
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data) as AiChatStreamEvent;
|
||||
onEvent(parsed);
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
onError?.(error.message);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return abortController;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 45px - 2 * var(--mantine-spacing-md));
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messageListWrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.messageErrorFallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.scrollToBottomButton {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-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);
|
||||
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scrollToBottomButton:hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.scrollToBottomButton:active {
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
/* Empty state - Notion AI style centered layout */
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.emptyStateBrand {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
margin-bottom: var(--mantine-spacing-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyStateInput {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-bottom: var(--mantine-spacing-xl);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.suggestionsSection {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.suggestionsLabel {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.suggestionsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.suggestionCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.suggestionText {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 0 var(--mantine-spacing-sm) 0;
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.toolbarSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.titleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
max-width: 60%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleButton:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.titleText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--mantine-spacing-sm) 0;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
padding-top: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--mantine-spacing-md);
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: var(--mantine-font-size-lg);
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quickActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quickAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.quickActionIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.historyList {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
cursor: pointer;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
transition: background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
|
||||
.historyItemTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: 16px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
box-shadow: light-dark(
|
||||
0 2px 40px 4px rgba(0, 0, 0, 0.07),
|
||||
0 2px 40px 4px rgba(0, 0, 0, 0.5)
|
||||
);
|
||||
transition:
|
||||
border-color 150ms,
|
||||
box-shadow 150ms;
|
||||
|
||||
&:focus-within {
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
box-shadow: light-dark(
|
||||
0 4px 48px 6px rgba(0, 0, 0, 0.09),
|
||||
0 4px 48px 6px rgba(0, 0, 0, 0.6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapperFlat {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: 12px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
box-shadow: none;
|
||||
transition: border-color 150ms;
|
||||
|
||||
&:focus-within {
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.attachmentChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 14px 0;
|
||||
}
|
||||
|
||||
.attachmentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.attachmentChipUploading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.attachmentChipName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachmentChipRemove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
border-radius: 50%;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
}
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
overflow: hidden;
|
||||
|
||||
:global(.ProseMirror) {
|
||||
outline: none;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 14px 18px 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-height: 24px;
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
:global(.ProseMirror p) {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
:global(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 12px 10px;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms, opacity 150ms;
|
||||
background: light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
|
||||
color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-9));
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
&:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
transition: color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
}
|
||||
|
||||
.plusButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
transition: color 150ms, background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
}
|
||||
|
||||
.plusMenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plusMenuIcon {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms;
|
||||
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));
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user