mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
997 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 523bca3a20 | |||
| d5be528d4b | |||
| 3ada075b1a | |||
| afce302b59 | |||
| 8e8433fa9d | |||
| ff42fa0b8c | |||
| ccea7f7a65 | |||
| 45a5b4e588 | |||
| 82cce365f7 | |||
| ed7e2badca | |||
| ba7b99fb7d | |||
| 71aa8f8051 | |||
| 7c9e945b8c | |||
| f6b3931bc4 | |||
| 9e3041305c | |||
| 78fc557143 | |||
| 8a2fec8de0 | |||
| e109dc0b51 | |||
| 88d980c657 | |||
| 3f489880da | |||
| 45fa6fd0d3 | |||
| a8c27f9d4a | |||
| 288d33ba42 | |||
| e7fb78dc1e | |||
| 4d3bf390a5 | |||
| 001b2365a1 | |||
| 7d5dadc441 | |||
| c912ad4b01 | |||
| bd6cd55a13 | |||
| 757764d046 | |||
| 94e64acc34 | |||
| 70ba24bfe1 | |||
| 32f431e879 | |||
| 906d8821a4 | |||
| 82b16a4bf5 | |||
| 069269e69c | |||
| 534149ba22 | |||
| 2dd6e04b44 | |||
| 0e3d9f6ddc | |||
| 3b7442c2d5 | |||
| 78b45d7c19 | |||
| 9e5100c71c | |||
| fccf13a7e2 | |||
| 09431f725c | |||
| 13162c0920 | |||
| e25b513d0b | |||
| 9012bffabc | |||
| 24a85b0f91 | |||
| 43a503b593 | |||
| a81fe3da0a | |||
| 70ba4d5435 | |||
| 881b9d0939 | |||
| 758de855bf | |||
| 9652874bbd | |||
| 840f5e82aa | |||
| d59b3334dc | |||
| 5a64d8994e | |||
| e6222894e9 | |||
| 9d48c06068 | |||
| 9f70b56a3a | |||
| 232dc78cc9 | |||
| d2c44380a4 | |||
| 2f9d7adf4a | |||
| ba4a64241b | |||
| ee14f706c8 | |||
| 1cc43f63df | |||
| 3450bd59f8 | |||
| 457d436cf6 | |||
| 1127efb9c4 | |||
| 0a98d3c2e7 | |||
| 5eaf7492dc | |||
| ee31c78db8 | |||
| edf14e2ebc | |||
| 2aad8f465c | |||
| 16b81a8356 | |||
| 5984adb2ea | |||
| f8eb1915fe | |||
| b556c636eb | |||
| b20db1428d | |||
| 4a5a59cb78 | |||
| 20bf9c2312 | |||
| 9f57ab4517 | |||
| 292e443dbe | |||
| 2d0414b4a3 | |||
| e612de9143 | |||
| c857d38bcd | |||
| d7a71c0572 | |||
| 58c061e653 | |||
| 22d1d06d39 | |||
| 290f566daa | |||
| 8ca2507050 | |||
| 9c666a0aaf | |||
| b3f2f7308a | |||
| af9b31c1ff | |||
| d7d1493289 | |||
| 54e042b736 | |||
| 0ba31847eb | |||
| 26ab39dc21 | |||
| 00be0eab05 | |||
| ed97bb1deb | |||
| 51387b0af1 | |||
| 1559ed12bd | |||
| c1b9d11173 | |||
| 2ab8b401fb | |||
| 49af7a8b0d | |||
| dd90c6d424 | |||
| 3d887f15ab | |||
| 82bb08e685 | |||
| 4f3368502a | |||
| 0d534f13cf | |||
| ffa10cac65 | |||
| b85f8c5bca | |||
| da39b570eb | |||
| 151950d08a | |||
| e562d7a7ec | |||
| d0383c06c3 | |||
| 5978eec270 | |||
| 242d1bf8d4 | |||
| 4a8260dfbc | |||
| 076a752ee7 | |||
| 545d62c400 | |||
| f8542b4d87 | |||
| c2fea0a26a | |||
| 25bdf56d16 | |||
| d07b508a77 | |||
| 9ddb2f4cd0 | |||
| 5691149a82 | |||
| 4974013995 | |||
| bc192d3106 | |||
| 4db6cbef22 | |||
| f79385cf2a | |||
| db2c11e4a5 | |||
| e57c6773fc | |||
| 4bdc032f97 | |||
| 777b68f87b | |||
| 66a7de09c1 | |||
| a19ae9e653 | |||
| 38f4c9aecb | |||
| 802d78b577 | |||
| 3f61e1ca38 | |||
| 8e04deb0f5 | |||
| 160bd02f13 | |||
| 68a3036909 | |||
| ec4aaa628f | |||
| 2c0894b330 | |||
| bd2bdebc33 | |||
| 2857ff594c | |||
| 4f01a10277 | |||
| ee805369d1 | |||
| 6a718fccea | |||
| 01ed60e2d5 | |||
| 8042db8d7a | |||
| 21649d3cf0 | |||
| b9395e1e36 | |||
| 10d1f8d428 | |||
| 0c00f8e0b3 | |||
| 71637a8483 | |||
| 189b257254 | |||
| cd2f50bc89 | |||
| 530550455d | |||
| 9a31fcac7b | |||
| 677157de1d | |||
| b5b1d32b31 | |||
| ae4dfc48cc | |||
| 3b487519a5 | |||
| 1425c4e05b | |||
| a84aedc3b4 | |||
| 4b7ba6cb3f | |||
| 5952e02971 | |||
| 8cd5aa0d23 | |||
| c0aa252f9a | |||
| 8a58ce51c0 | |||
| 9c2decb095 | |||
| 5e9c8d2c43 | |||
| 39f13881c5 | |||
| 3b94727c07 | |||
| 4a5a461d25 | |||
| 1963573db4 | |||
| 5046e1a2e0 | |||
| a1f3b4476e | |||
| 8defc90e95 | |||
| b2a39a3071 | |||
| 21511c2f68 | |||
| 0e5c819f7c | |||
| 0f44d7d264 | |||
| e078a9d9e1 | |||
| fef12b0e8b | |||
| df075630fb | |||
| bffb55d8c0 | |||
| 5c24213b0e | |||
| 12a457801a | |||
| ae4d317dc3 | |||
| f7c6854059 | |||
| bdb6b01765 | |||
| 129dfabaa3 | |||
| 8a6d1b2aaf | |||
| 465b78411a | |||
| 272b32b410 | |||
| 7945e752d6 | |||
| 6eb3ab38fb | |||
| c7a9210215 | |||
| d5d63aa979 | |||
| 84574020f2 | |||
| 1b7ea2c87d | |||
| 47b7678975 | |||
| da70388f4b | |||
| 6c1a795460 | |||
| 75d23eb6aa | |||
| 0c4de72356 | |||
| 5e8602c50a | |||
| 61b8070626 | |||
| 5caaeff67c | |||
| 92a1f9c448 | |||
| 58a8e97f94 | |||
| 815b725f87 | |||
| d80bbd5bed | |||
| 293506217e | |||
| 9739542a3a | |||
| 9f3a88223d | |||
| 409a63633c | |||
| 125436fa87 | |||
| 975846c236 | |||
| 7befb7d555 | |||
| 099255761c | |||
| c8fc21b8bd | |||
| 9186b8c850 | |||
| e38c5fed44 | |||
| 3b069bc543 | |||
| 618b1b8697 | |||
| e45a0efce3 | |||
| 597a5f7a1d | |||
| 42c216b00b | |||
| f3751ab9aa | |||
| 9e8d101d63 | |||
| 5656731850 | |||
| 7c4ac70db3 | |||
| bfe84b3016 | |||
| f349e567f8 | |||
| ff434f4515 | |||
| 0c2e0cad5c | |||
| 326f9c0823 | |||
| 6df5edfbdb | |||
| 5023406717 | |||
| 5be805910c | |||
| 191d59166c | |||
| 09948dd804 | |||
| 875c91e5ff | |||
| 801ffbfb7b | |||
| a1a7795945 | |||
| 4491b109ee | |||
| 9789c51d4f | |||
| 4362406e74 | |||
| 04c58e6e0f | |||
| ba86de3656 | |||
| 607498cabe | |||
| 35321076cf | |||
| a5a7ee9916 | |||
| 33bb2c6863 | |||
| b0d97707ba | |||
| f0e8cf8257 | |||
| 280fcecabb | |||
| a07e76c740 | |||
| f35c503658 | |||
| 53c44fa8ba | |||
| ee3966d6c8 | |||
| 06f68a462b | |||
| 0104ecfee8 | |||
| a3f368d547 | |||
| a438652a50 | |||
| a8899a551b | |||
| f7da46c785 | |||
| 14b305c600 | |||
| be71425bb7 | |||
| cf4052307d | |||
| 4e3b27c712 | |||
| 85d72c831d | |||
| bb3543efa6 | |||
| 0e70857d78 | |||
| d3b5ca451b | |||
| b194e8317d | |||
| bb8783d217 | |||
| 8c7567faf3 | |||
| 1268d3e7b1 | |||
| 80e1574c26 | |||
| 9cbe20cbde | |||
| fc6430d5ad | |||
| d6aa18c063 | |||
| 563b338ee3 | |||
| 5ea4095beb | |||
| 81d3d6cc7d | |||
| e695e0f62d | |||
| 00e96baf0e | |||
| 1a3407a218 | |||
| efeff0ba9e | |||
| b3571f391a | |||
| 65931a1777 | |||
| d04a4bcbf8 | |||
| 1d4f18bdf9 | |||
| bb160a4010 | |||
| ff2b33d83b | |||
| 6a23118342 | |||
| 13af757ad1 | |||
| bae24ad4af | |||
| f60e611577 | |||
| 5b99efce06 | |||
| eb8ec8d793 | |||
| f4b07422ac | |||
| 137ae27cb8 | |||
| d3eab7d973 | |||
| bf2c6d35b5 | |||
| 0a408c21ac | |||
| 98340aa855 | |||
| 714e2ad703 | |||
| aa32b1f372 | |||
| 375ae53566 | |||
| f686902cd3 | |||
| b0f3440221 | |||
| 707b3f227c | |||
| 24bcf6ded8 | |||
| 240b10a192 | |||
| 88e1d075e0 | |||
| 87de60d8de | |||
| e395935f6a | |||
| 3a52b80e3a | |||
| 7e3cb29c57 | |||
| c60332dcf1 | |||
| 6c253c71c3 | |||
| 33c63d34e7 | |||
| 149aa4c5e2 | |||
| 1f68ba1ea1 | |||
| c0c59b6d80 | |||
| 479ab49d67 | |||
| 1a51f8e3e1 | |||
| 7fca16d866 | |||
| e629548a42 | |||
| c39182616b | |||
| 1d9a6acc01 | |||
| 18da5aed39 | |||
| 60c5755647 | |||
| b84381a8de | |||
| c19e65b46b | |||
| 44f5f7d114 | |||
| f46f484d5f | |||
| bf3649942c | |||
| 91f7c3778f | |||
| abed22661a | |||
| 57503a6a10 | |||
| 34df665944 | |||
| e179769a8f | |||
| 0d7238300e | |||
| e3dea0a3ea | |||
| 6a19807a72 | |||
| 4680aa254d | |||
| 137c6ff9dd | |||
| af789b7f7c | |||
| 0fe1c443e9 | |||
| ecbb1de8de | |||
| 9c42a01391 | |||
| 7abfb4deba | |||
| ad27c5f6be | |||
| 86be4d7997 | |||
| a2c05f3caa | |||
| 62453ebefa | |||
| e198791139 | |||
| e1a7558647 | |||
| 981b667fbb | |||
| 1b45571e63 | |||
| 3ad1bef134 | |||
| 85e017ff85 | |||
| 133676d05b | |||
| f323952012 | |||
| 2215395a26 | |||
| caa9e0503e | |||
| 1d9012d9da | |||
| f67567dbcf | |||
| 344b769583 | |||
| 9f4523a8ce | |||
| efeb22558c | |||
| de157cb87b | |||
| 2d9f545c57 | |||
| 5564bce133 | |||
| 7c2df01a5e | |||
| 1d109435ad | |||
| 47d9cce936 | |||
| bfd2553d1e | |||
| 2b1889b9a9 | |||
| 468035fc3c | |||
| 467d35702b | |||
| d0337b1b6d | |||
| d680cab0f6 | |||
| 4976fe5e7f | |||
| 42c12ea26d | |||
| a6a12acad7 | |||
| 956c4270df | |||
| 13956804c2 | |||
| aa1261e82b | |||
| 38cd318a82 | |||
| eff3fcfe10 | |||
| 0257e0d842 | |||
| 7871c06059 | |||
| bcc37d6b7d | |||
| c96044f4f7 | |||
| 0f6be35870 | |||
| f47852d689 | |||
| 4e683e92ec | |||
| 3b080ac116 | |||
| 0efa316004 | |||
| 7a22d742ab | |||
| a4727c4c53 | |||
| 577f2b05ca | |||
| 1585c472c2 | |||
| dd8d2ae54a | |||
| e3a5bc0f77 | |||
| 535c06bb3f | |||
| 6a632137ed | |||
| f82f00216b | |||
| be248e1ad4 | |||
| abc5ee2aa7 | |||
| e290c7c522 | |||
| f20eb6639f | |||
| d0176d7ed6 | |||
| 8402f3bcfd | |||
| 6caa966a52 | |||
| 098918b416 | |||
| 4670d4914c | |||
| 3ce9962b32 | |||
| 4b1286d53c | |||
| cc2a2ddca3 | |||
| 4ad1ccf5dd | |||
| ac9c5784ee | |||
| cb3aeda8e0 | |||
| 9b1baaf7b8 | |||
| 81a360f9a7 | |||
| a74a6313dd | |||
| 89a109560e | |||
| ce36b550c3 | |||
| 1187883c6b | |||
| cef86cbcd9 | |||
| bf23b2d2f2 | |||
| 4a16442db0 | |||
| 7c0a0d5f39 | |||
| 8f1445e6df | |||
| e91ee04d93 | |||
| 583ac6d4d9 | |||
| 8212f3c023 | |||
| 35d676e76e | |||
| 41f1dd9ce5 | |||
| 5b44fe68b1 | |||
| 54f280c366 | |||
| 3eb0812c97 | |||
| f2908fdd65 | |||
| 830f6c0706 | |||
| 0df90086bf | |||
| 5c0d819fc1 | |||
| 1f3e27765a | |||
| 89c10ccedb | |||
| 91bde5cb5a | |||
| 059a0a24c5 | |||
| 576ad85c08 | |||
| 63784d86a3 | |||
| add979a9f5 | |||
| 4226dd405f | |||
| 28c7013252 | |||
| fa810c3bab | |||
| 5e96c877a6 | |||
| 93d5ab7fcd | |||
| 91c9421b5e | |||
| a565f3c665 | |||
| 78b465a815 | |||
| 6aeec0ead1 | |||
| 3ccafb9a7b | |||
| caa6b7ecca | |||
| 6883f2fdf9 | |||
| 4b0cda41cf | |||
| 1646caa66b | |||
| 39db61cc76 | |||
| 46449d374a | |||
| 978df648eb | |||
| a012dffa22 | |||
| 729526bd34 | |||
| c13b28ae8f | |||
| 306012c4c5 | |||
| ab97e38f68 | |||
| d4bb8be86b | |||
| cbdfe74bb9 | |||
| 2b7057b922 | |||
| bd0b7746ab | |||
| 009b9f838a | |||
| 2d17ec60db | |||
| 9dc91b08a9 | |||
| 955a3cff78 | |||
| 741a8d3f09 | |||
| 525dc6ebd2 | |||
| 8c7d1f8fa6 | |||
| dba655d6e8 | |||
| cb8280249f | |||
| 68b660e547 | |||
| f594cbc21b | |||
| e991f834e2 | |||
| b0633b1d36 | |||
| d8da0fffa5 | |||
| 9e23766b51 | |||
| 8e69ad44f0 | |||
| fd48169219 | |||
| 0e3e6df1f0 | |||
| 9390a2e9c6 | |||
| c96360c7f8 | |||
| 4cd3ec7cc7 | |||
| d9d389d090 | |||
| 504195a324 | |||
| 47b880221d | |||
| 3c31902885 | |||
| 81851d8367 | |||
| 2f4e067a65 | |||
| aacfd24b58 | |||
| 8c8bd5bc37 | |||
| a2359dd769 | |||
| 781861f799 | |||
| b4922322ae | |||
| 5bcadb3cc6 | |||
| 2cc79b3d16 | |||
| c671b5ff17 | |||
| d60ab3672e | |||
| 5271463064 | |||
| 96080e8a03 | |||
| a6ea73eab6 | |||
| 4ba6005ca3 | |||
| c4e6c12282 | |||
| 09ab829b17 | |||
| 66a057a070 | |||
| f2ffea5ba4 | |||
| b0dee4dafb | |||
| c5a6b78c32 | |||
| beb48af8ed | |||
| e2be3ec191 | |||
| 68a1f9683e | |||
| 5c57116a68 | |||
| 48508b9df4 | |||
| c8250256a7 | |||
| 6491e1f986 | |||
| 03757ed0af | |||
| a676dbe881 | |||
| 411d8620ba | |||
| f45f56318a | |||
| 3ae0f3f819 | |||
| 306626ee1c | |||
| 7e0fe3b1b9 | |||
| fdbc015dbf | |||
| 7d8e3912b4 | |||
| 9ebca725ae | |||
| 4105abcd0f | |||
| 9718187490 | |||
| aa0620e01f | |||
| 955776b492 | |||
| 9b11abbf4a | |||
| cc613771fa | |||
| 5cc81ae4b0 | |||
| 94b74f96a3 | |||
| 48bf149d01 | |||
| f3679739d8 | |||
| 38206883ff | |||
| dd21074c27 | |||
| cd5a6c7491 | |||
| 6e6e0a370e | |||
| 83bac11173 | |||
| ecf69225e1 | |||
| c6148ba4f2 | |||
| 9ee5d21c3a | |||
| d5cc2432c4 | |||
| 7f077d949d | |||
| 312bc715bf | |||
| 6ba08352ed | |||
| 58874a1ccb | |||
| 82f08360d7 | |||
| 978d26f36c | |||
| 18eee16d2d | |||
| c274846275 | |||
| 7821993450 | |||
| a9d6ce87c1 | |||
| 67b21d5fe3 | |||
| 8b488efc8e | |||
| 070b75b6be | |||
| 51c4afd5f7 | |||
| 74b3b0f9ae | |||
| 1236f3281d | |||
| 4a0d586768 | |||
| 079964bec8 | |||
| b0b85fff3a | |||
| 0d3a10120a | |||
| b8c3d5b3d1 | |||
| 959015928f | |||
| d8ee545002 | |||
| 78b9536de9 | |||
| 4e4afe2545 | |||
| 38afba0820 | |||
| 81742dbb85 | |||
| 3898e5f7e2 | |||
| 6a36efbf1a | |||
| 991b4065e3 | |||
| c158df1bc5 | |||
| f03705848d | |||
| 0c99eb1d07 | |||
| 7b37d337c1 | |||
| 69ae6f93db | |||
| 71c1683bb3 | |||
| 6df8b2555d | |||
| 16cadeb09e | |||
| fc29c5f7d0 | |||
| 399684cc19 | |||
| a038dbd8da | |||
| f225f45f50 | |||
| 58b7c2e7ac | |||
| b8058a2755 | |||
| aa244dd548 | |||
| 33d8953554 | |||
| c39ae2b965 | |||
| 3413d3f77d | |||
| c9e3185ad0 | |||
| f8cf37a9bd | |||
| 20709d23ee | |||
| e4065c276b | |||
| 11b6974387 | |||
| 554a7d7530 | |||
| 2baf407809 | |||
| 259ff53bfb | |||
| 21063e6230 | |||
| 1285da063e | |||
| 3e9e3fcc9e | |||
| ba4bfc693a | |||
| 179938e904 | |||
| 4e13a59338 | |||
| 2c9e71c91d | |||
| 733567d088 | |||
| 5b25c60b62 | |||
| d7efa9d914 | |||
| c70f5284c7 | |||
| b40bea036f | |||
| 6da7843bf0 | |||
| 9f0ec8199f | |||
| 9bff25558e | |||
| 00b96eb678 | |||
| 3d0249e076 | |||
| 1bddb3c588 | |||
| b26023e32a | |||
| c8421eb1fc | |||
| 8c125738e8 | |||
| 6d92e14515 | |||
| 0b36427c09 | |||
| 1ea0eb9965 | |||
| c4c3ea1e6d | |||
| 43c801232e | |||
| 6825a4a0c1 | |||
| 8a4a8b58be | |||
| be975f38a6 | |||
| fa37d5b3f7 | |||
| 0ddd0c14b2 | |||
| 297cfda32b | |||
| d8367ec878 | |||
| 79057327fa | |||
| 0943184b1e | |||
| 3f612c4d26 | |||
| a4752ae692 | |||
| e6068d44b0 | |||
| 877e1a09cc | |||
| bca82b3f8c | |||
| 1aea2fcee8 | |||
| 504713d920 | |||
| 50d2a211e5 | |||
| 5d3a740791 | |||
| 2c1c77f367 | |||
| 68f0d399ca | |||
| 1305a07502 | |||
| c9dd8e1192 | |||
| 860739b28b | |||
| 80d013dd19 | |||
| 2469739bca | |||
| 2197e0e1fd | |||
| 846db9d076 | |||
| a307d8d1c9 | |||
| ae0d48ac83 | |||
| 6400c2d27d | |||
| fc28996420 | |||
| 929105f0e4 | |||
| 93c0d6fe78 | |||
| 88a40c3294 | |||
| c056401000 | |||
| eae799c7d6 | |||
| 20ce7460c1 | |||
| d765a80ea3 | |||
| b6686a462f | |||
| 9ddb101135 | |||
| 1dc189b466 | |||
| e624ee337f | |||
| 6ba5df0215 | |||
| 897e1bff26 | |||
| ba14636c1d | |||
| 6c72295424 | |||
| f6faaa23b0 | |||
| ba737a9920 | |||
| 98813a9b40 | |||
| e0105115f4 | |||
| 7d51eadf90 | |||
| 66740887e7 | |||
| 69deaf9969 | |||
| 217458da81 | |||
| 61a5e42403 | |||
| 07546c4790 | |||
| f4f768a1b3 | |||
| a9c392e26e | |||
| 90af1332e8 | |||
| de4bdb4a99 | |||
| 8dd22ab8a3 | |||
| fa25ff29bb | |||
| 21f87d9b91 | |||
| 0115987e52 | |||
| 6c138ca924 | |||
| 1adc2fec86 | |||
| 8c7f8d6ad1 | |||
| 2ae9da3153 | |||
| b4741c31a9 | |||
| cfdbf9235f | |||
| 059158d087 | |||
| 77393ff40b | |||
| 64d4a20403 | |||
| 6b94c0632c | |||
| cb124ba3ec | |||
| ba01b4acac | |||
| ce72f45d9a | |||
| bf2eea18c3 | |||
| 501bab0f69 | |||
| 5dd80d5cb8 | |||
| 8f6de3cd23 | |||
| 816696d0fe | |||
| bb54fda6dc | |||
| 36f2292f2d | |||
| 905c7d460b | |||
| d48714d17a | |||
| a0db42fbfe | |||
| 82a3940a2c | |||
| b224f8b713 | |||
| be03fffcae | |||
| 1e27a62b53 | |||
| d418d85d02 | |||
| a7d3f9fc06 | |||
| 7a169d0596 | |||
| cf968969d0 | |||
| c20d0256c8 | |||
| c4236d6737 | |||
| 4b8cfc78b8 | |||
| f7c965bc6b | |||
| 78a91ccb95 | |||
| 8e9f8784dc | |||
| 5be2e9b268 | |||
| f4d0ccb454 | |||
| a40983e65e | |||
| f32c103fe1 | |||
| 0b77fe5292 | |||
| 9afb51fcc0 | |||
| 4e10028669 | |||
| d4e16ebe49 | |||
| 1e44b25a0c | |||
| 4ff03a1f2c | |||
| 40f7c00adb | |||
| b43d8d119f | |||
| 74e3f85866 | |||
| bbf3f0cae8 | |||
| c0e9a771d6 | |||
| c49272efc1 | |||
| 979322025d | |||
| f0131632a7 | |||
| ffe91604b5 | |||
| e7fa8f5da9 | |||
| 3256f5156d | |||
| d45073a0bd | |||
| a4d6348a79 | |||
| c944a7d101 | |||
| 45e0c7e546 | |||
| 32b63adc68 | |||
| b1cca15f6f | |||
| dfeb7b3db7 | |||
| 50424fc574 | |||
| 12a910876e | |||
| d73a5e223c | |||
| fd9567e3fe | |||
| ae04071466 | |||
| 2ab3f59722 | |||
| 7257fac859 | |||
| 1a4c04e239 | |||
| 39a495714f | |||
| fabf5a7e26 | |||
| e71bd6768e | |||
| 71403e6303 | |||
| 43fc4db00e | |||
| e9ee2d4b0d | |||
| 228cb05932 | |||
| 505bf04a1f | |||
| 41bfcf2f76 | |||
| e308204808 | |||
| 411d5408c1 | |||
| 45684d9e44 | |||
| 0ebcff9504 | |||
| edafe01387 | |||
| 16277a3811 | |||
| ef5b381f8e | |||
| ef9880a2a5 | |||
| 95cb81b0e5 | |||
| 7d0ae631b8 | |||
| 5c04074d54 | |||
| e89ba2ecfc | |||
| 4ebf9c5f11 | |||
| add0b17e04 | |||
| 60906cf1d1 | |||
| 9292acb979 | |||
| be57b7130f | |||
| b88a8fcbb5 | |||
| 040840917c | |||
| 44e5f07f59 | |||
| c9e61859ce | |||
| 862f59b77a | |||
| 871bfd7dfd | |||
| 4d596f2ff9 | |||
| 8c85ea3644 | |||
| 19350fbc3e | |||
| 358afd2428 | |||
| 7a314a92b1 | |||
| e03505dca2 | |||
| ce8d498f2d | |||
| b109c1340a | |||
| e10f6bf9af | |||
| 6f5550dc50 | |||
| dfdd473eca | |||
| b515880adb | |||
| 78695b4e03 | |||
| 0ee53e7b38 | |||
| 1b28bd96d4 | |||
| bba50f038b | |||
| 701a8ab03a | |||
| ccb5f9df1f | |||
| c9341eda3f | |||
| fb2e8d8209 | |||
| 27fb9246e6 | |||
| 9a2c7c5db6 | |||
| d1ad5da919 | |||
| 1fbc19ad4f | |||
| 23edfe3dfc | |||
| 1ff8546484 | |||
| 6d18d5ed2d | |||
| 6d5067247c | |||
| 5e05bcd0db | |||
| 5f71b85c06 | |||
| d74133745a | |||
| eee2bbe47a | |||
| c1bce755ca | |||
| 015be3d53a | |||
| 7d3b37a2a3 | |||
| ff1c1ed56a | |||
| d5674e9a11 | |||
| 7eabe65bcf | |||
| 3444e3f446 | |||
| 9e3ac1e490 | |||
| c38e70e244 | |||
| ce7215341f | |||
| 4733955531 | |||
| 36267de117 | |||
| cd13399da5 | |||
| 36cd2feca5 | |||
| fbe3b5b17e | |||
| 10107ecf31 | |||
| 94d698e39f | |||
| 6c88a01123 | |||
| 75af89de30 | |||
| ed8518aca4 | |||
| 7522f396e7 | |||
| 9b2f083e4b | |||
| 9a949d7391 | |||
| 13904fb702 | |||
| f7160e6dec | |||
| 1983691950 | |||
| 6866644d0c | |||
| b120aabaa3 | |||
| 1d442c1d7a | |||
| 9a0294360c | |||
| 9de0c5b051 | |||
| 9e9b86f1b4 | |||
| 8ff5ec486f | |||
| 5576339bcc | |||
| e668e80f1c | |||
| 3aaa6e916b | |||
| ad329eddb9 | |||
| 990e804bd3 | |||
| 299e26bebe | |||
| 96b6d7d81f | |||
| 27d5c3400c | |||
| bb9c0c9b68 | |||
| 483190e7c1 | |||
| c89ff8b551 | |||
| 63232e56a3 | |||
| 643504d89b | |||
| 2288f9d2fc | |||
| 804c2586a9 | |||
| fedd559fd6 | |||
| 5f07bdaaf1 | |||
| fb643a1ade | |||
| 069fd99341 | |||
| 3dc760484a | |||
| 13580ea5fb | |||
| aa5dd1abc6 | |||
| de444bf770 | |||
| 821f71ac28 | |||
| faebc62917 | |||
| 41e572445c | |||
| 66f5ea50c5 | |||
| ce4b8088ec | |||
| b1138eb9db | |||
| 8412f303dd | |||
| 7272e0bbfd | |||
| c7eaf3aa79 | |||
| deef5e6b81 | |||
| 6d72006b28 | |||
| 26c1676cdd | |||
| 4ddfa92c14 | |||
| 19c9e17884 | |||
| 14ef2d4a4a | |||
| de859318fa | |||
| bcbb516448 | |||
| 71870e4567 | |||
| 9819473157 | |||
| eb7984f40d | |||
| 9caa0acc24 | |||
| 8ddfa8fde0 | |||
| 41d4b2a8be | |||
| 10ebf46a98 | |||
| 70809d6c27 | |||
| a314ba2b80 | |||
| d8f03f6bea | |||
| 533d6f84d8 | |||
| 095cb1b9d1 | |||
| 0a0205fcf9 | |||
| 9aed5ff2ed | |||
| d189d6d776 | |||
| 262905e357 | |||
| 4a4643f33f | |||
| a6a7edf0b2 | |||
| 949d0967d2 | |||
| cd634093af | |||
| 7201380504 | |||
| ba87a7f876 | |||
| 9f1b0554d6 | |||
| 1166a09835 | |||
| 6f2d7c8f5e | |||
| e6c4c22a1d | |||
| 9a044ada28 | |||
| da5e77f78d | |||
| cc8be328f9 | |||
| f1c4155d81 | |||
| d4899a8dee | |||
| a973a1b4f8 | |||
| 73b0534053 | |||
| 931c5bd990 | |||
| ee54308819 | |||
| 66b00c24e2 | |||
| f6d08582ec | |||
| 8d9a511edf | |||
| 3059d53d11 | |||
| 3074724f2f | |||
| 21ed7ea4a2 | |||
| 267271d97a | |||
| 874c1292c7 | |||
| a9948499e4 | |||
| 3dd15499e6 | |||
| 393e99201a | |||
| 153b7f64b7 | |||
| 7b2d45665c | |||
| 37873dd938 | |||
| 90301e62ce | |||
| 377422a9d5 | |||
| d90a059dfa | |||
| 1e20f024d5 | |||
| 9a81baa809 | |||
| 11b85a2d70 | |||
| d04629605e | |||
| 187989cc1d | |||
| 6444b2b4ce | |||
| 42ebc7c298 | |||
| 8bca921b30 | |||
| 12f8b6eb55 | |||
| 202cfb6a63 | |||
| b6f9664ec2 | |||
| 9f8075171d | |||
| 02b907e764 | |||
| e05e021f41 | |||
| 615c6bae58 | |||
| 62fbc26811 | |||
| 2171203a4c | |||
| b28b483b90 | |||
| 020cafade1 | |||
| e4b2262d4d |
+27
-1
@@ -5,6 +5,32 @@ client/dist
|
|||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.git
|
.git
|
||||||
.env
|
.github
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
*.log
|
*.log
|
||||||
*.md
|
*.md
|
||||||
|
!client/**/*.md
|
||||||
|
chart/
|
||||||
|
docs/
|
||||||
|
docker-compose.yml
|
||||||
|
unraid-template.xml
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
**/coverage
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
sonar-project.properties
|
||||||
|
server/tests/
|
||||||
|
server/vitest.config.ts
|
||||||
|
server/reset-admin.js
|
||||||
|
**/*.test.ts
|
||||||
|
wiki/
|
||||||
|
scripts/
|
||||||
|
charts/
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Normalize line endings to LF on commit
|
||||||
|
* text=auto eol=lf
|
||||||
|
# Explicitly enforce LF for source files
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.jsx text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.html text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
# Binary files — no line ending conversion
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: "[BUG]"
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Create a report to help us improve TREK
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: []
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: preflight
|
||||||
|
attributes:
|
||||||
|
label: Pre-flight checklist
|
||||||
|
options:
|
||||||
|
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
|
||||||
|
required: true
|
||||||
|
- label: I am running the latest available version of TREK
|
||||||
|
required: true
|
||||||
|
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: TREK version
|
||||||
|
description: Found in the Settings → About, or in the Docker image tag
|
||||||
|
placeholder: "e.g. 2.8.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear and concise description of what the bug is.
|
||||||
|
placeholder: When I do X, Y happens instead of Z…
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Step-by-step instructions to reliably trigger the bug.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: deployment
|
||||||
|
attributes:
|
||||||
|
label: Deployment method
|
||||||
|
options:
|
||||||
|
- Docker Compose
|
||||||
|
- Docker (standalone)
|
||||||
|
- Kubernetes / Helm
|
||||||
|
- Unraid template
|
||||||
|
- Sources
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Host OS
|
||||||
|
placeholder: "e.g. Ubuntu 24.04, Unraid 6.12, Synology DSM 7"
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: user_os
|
||||||
|
attributes:
|
||||||
|
label: Accessing TREK from
|
||||||
|
options:
|
||||||
|
- Desktop browser
|
||||||
|
- Mobile browser
|
||||||
|
- Mobile app (PWA)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: Browser (if applicable)
|
||||||
|
placeholder: "e.g. Chrome 124, Firefox 125, Safari 17"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant logs or error output
|
||||||
|
description: Paste any relevant server or browser console output here.
|
||||||
|
render: shell
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: Drag and drop screenshots here if applicable.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Anything else that might help us understand the issue.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Documentation
|
||||||
|
url: https://github.com/mauriceboe/TREK/wiki
|
||||||
|
about: Check the docs before opening an issue
|
||||||
|
- name: Feature Request
|
||||||
|
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
|
||||||
|
about: Suggest a new feature or improvement in Discussions
|
||||||
|
- name: Questions & Help
|
||||||
|
url: https://github.com/mauriceboe/TREK/discussions
|
||||||
|
about: For questions and general help, use Discussions instead
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: "[FEATURE]"
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
## Description
|
||||||
|
<!-- What does this PR do? Why? -->
|
||||||
|
|
||||||
|
## Related Issue or Discussion
|
||||||
|
<!-- This project requires an issue or an approved feature request before submitting a PR. -->
|
||||||
|
<!-- For bug fixes: Closes #ISSUE_NUMBER -->
|
||||||
|
<!-- For features: Addresses discussion #DISCUSSION_NUMBER -->
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Breaking change
|
||||||
|
- [ ] Documentation update
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||||
|
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||||
|
- [ ] This PR targets the `dev` branch, not `main`
|
||||||
|
- [ ] I have tested my changes locally
|
||||||
|
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||||
|
- [ ] I have updated documentation if needed
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Close issues with unchanged bad titles
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */6 * * *' # Every 6 hours
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close stale invalid-title issues
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const badTitles = [
|
||||||
|
"[bug]", "bug report", "bug", "issue",
|
||||||
|
"help", "question", "test", "...", "untitled"
|
||||||
|
];
|
||||||
|
|
||||||
|
const { data: issues } = await github.rest.issues.listForRepo({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
labels: 'invalid-title',
|
||||||
|
state: 'open',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
const createdAt = new Date(issue.created_at);
|
||||||
|
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||||
|
|
||||||
|
const titleLower = issue.title.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!badTitles.includes(titleLower)) {
|
||||||
|
// Title was fixed — remove the label and move on
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
name: 'invalid-title',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still a bad title after 24h — close it
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
body: [
|
||||||
|
'## Issue closed',
|
||||||
|
'',
|
||||||
|
'This issue has been automatically closed because the title was not updated within 24 hours.',
|
||||||
|
'',
|
||||||
|
'Feel free to open a new issue with a descriptive title that summarizes the problem.',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Close PRs with unchanged wrong base branch
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */6 * * *' # Every 6 hours
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close stale wrong-base-branch PRs
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { data: pulls } = await github.rest.pulls.list({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
for (const pull of pulls) {
|
||||||
|
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||||
|
if (!hasLabel) continue;
|
||||||
|
|
||||||
|
const createdAt = new Date(pull.created_at);
|
||||||
|
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||||
|
|
||||||
|
// Base was fixed — remove label and move on
|
||||||
|
if (pull.base.ref !== 'main') {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pull.number,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still targeting main after 24h — close it
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pull.number,
|
||||||
|
body: [
|
||||||
|
'## PR closed',
|
||||||
|
'',
|
||||||
|
'This PR has been automatically closed because the base branch was not updated to `dev` within 24 hours.',
|
||||||
|
'',
|
||||||
|
'Feel free to open a new PR targeting `dev`.',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: pull.number,
|
||||||
|
state: 'closed',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: Flag issues with bad titles
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-title:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Flag or redirect issue
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const title = context.payload.issue.title.trim();
|
||||||
|
const titleLower = title.toLowerCase();
|
||||||
|
|
||||||
|
const badTitles = [
|
||||||
|
"[bug]", "bug report", "bug", "issue",
|
||||||
|
"help", "question", "test", "...", "untitled"
|
||||||
|
];
|
||||||
|
|
||||||
|
const featureRequestTitles = [
|
||||||
|
"feature request", "[feature]", "[feature request]", "[enhancement]"
|
||||||
|
];
|
||||||
|
|
||||||
|
if (badTitles.includes(titleLower)) {
|
||||||
|
// Ensure the label exists
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'invalid-title',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'invalid-title',
|
||||||
|
color: 'e4e669',
|
||||||
|
description: 'Issue title does not meet quality standards',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
labels: ['invalid-title'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
body: [
|
||||||
|
'## Invalid title',
|
||||||
|
'',
|
||||||
|
`Your issue title \`${title}\` is too generic to be actionable.`,
|
||||||
|
'',
|
||||||
|
'Please edit the title to something descriptive that summarizes the problem — for example:',
|
||||||
|
'> _Map view crashes when zooming in on Safari 17_',
|
||||||
|
'',
|
||||||
|
'**This issue will be automatically closed in 24 hours if the title has not been updated.**',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
body: [
|
||||||
|
'## Wrong place for feature requests',
|
||||||
|
'',
|
||||||
|
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||||
|
'',
|
||||||
|
'This issue has been closed. Feel free to re-submit your idea in the right place!',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
name: Build & Push Docker Image (Prerelease)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: 'Bump line for next prerelease (auto detects in-flight major)'
|
||||||
|
type: choice
|
||||||
|
options: [auto, minor, major]
|
||||||
|
default: auto
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: prerelease-build
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version-bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.bump.outputs.VERSION }}
|
||||||
|
sha: ${{ steps.bump.outputs.SHA }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine prerelease version
|
||||||
|
id: bump
|
||||||
|
run: |
|
||||||
|
git fetch --tags
|
||||||
|
|
||||||
|
# Capture the exact commit we're building so build/merge jobs are pinned to it
|
||||||
|
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Get latest stable tag (exclude prerelease tags)
|
||||||
|
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||||
|
STABLE="${STABLE_TAG#v}"
|
||||||
|
STABLE="${STABLE:-0.0.0}"
|
||||||
|
echo "Latest stable: $STABLE"
|
||||||
|
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||||
|
|
||||||
|
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
|
||||||
|
NEXT_MAJOR="$((MAJOR + 1)).0.0"
|
||||||
|
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
|
||||||
|
|
||||||
|
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||||
|
|
||||||
|
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
|
||||||
|
TARGET="$NEXT_MAJOR"
|
||||||
|
else
|
||||||
|
TARGET="${MAJOR}.$((MINOR + 1)).0"
|
||||||
|
fi
|
||||||
|
echo "Target: $TARGET"
|
||||||
|
|
||||||
|
# Find the highest existing prerelease N for this target and increment
|
||||||
|
LAST_N=$(git tag -l "v${TARGET}-pre.*" | sed 's/.*-pre\.//' | sort -n | tail -1)
|
||||||
|
N=$(( ${LAST_N:-0} + 1 ))
|
||||||
|
|
||||||
|
NEW_VERSION="${TARGET}-pre.${N}"
|
||||||
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "$STABLE → $NEW_VERSION"
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
needs: version-bump
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
- name: Prepare platform tag-safe name
|
||||||
|
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.version-bump.outputs.sha }}
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- 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=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
no-cache: true
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [version-bump, build]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.version-bump.outputs.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Download build digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create and push multi-arch manifest
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||||
|
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||||
|
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t "mauriceboe/trek:latest-pre" \
|
||||||
|
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||||
|
-t "mauriceboe/trek:$VERSION" \
|
||||||
|
"${digests[@]}"
|
||||||
|
|
||||||
|
- name: Inspect manifest
|
||||||
|
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre
|
||||||
|
|
||||||
|
- name: Push git tag
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag "v$VERSION"
|
||||||
|
git push origin "v$VERSION"
|
||||||
|
|
||||||
|
- name: Clean up old prerelease tags
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
KEEP=20
|
||||||
|
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||||
|
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
|
||||||
|
git fetch --tags
|
||||||
|
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
|
||||||
|
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
|
||||||
|
TOTAL=${#ALL_TAGS[@]}
|
||||||
|
DELETE_COUNT=$((TOTAL - KEEP))
|
||||||
|
if [ "$DELETE_COUNT" -gt 0 ]; then
|
||||||
|
for TAG in "${ALL_TAGS[@]:0:$DELETE_COUNT}"; do
|
||||||
|
echo "Deleting old prerelease tag: $TAG"
|
||||||
|
git push origin --delete "$TAG"
|
||||||
|
done
|
||||||
|
fi
|
||||||
@@ -3,11 +3,119 @@ name: Build & Push Docker Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.github/workflows/wiki.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: 'Force bump line (auto = patch/finalize as today)'
|
||||||
|
type: choice
|
||||||
|
options: [auto, patch, minor, major]
|
||||||
|
default: auto
|
||||||
|
confirm_major:
|
||||||
|
description: "Type MAJOR (all caps) to confirm a major release"
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: stable-build
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
version-bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.bump.outputs.VERSION }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine bump type and update version
|
||||||
|
id: bump
|
||||||
|
run: |
|
||||||
|
git fetch --tags
|
||||||
|
|
||||||
|
# Derive version from git tags — no package.json dependency
|
||||||
|
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||||
|
STABLE="${STABLE_TAG#v}"
|
||||||
|
STABLE="${STABLE:-0.0.0}"
|
||||||
|
|
||||||
|
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
|
||||||
|
|
||||||
|
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||||
|
|
||||||
|
if [ "$BUMP_INPUT" = "major" ]; then
|
||||||
|
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
|
||||||
|
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||||
|
BUMP="major"
|
||||||
|
elif [ "$BUMP_INPUT" = "minor" ]; then
|
||||||
|
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||||
|
BUMP="minor"
|
||||||
|
elif [ "$BUMP_INPUT" = "patch" ]; then
|
||||||
|
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
BUMP="patch"
|
||||||
|
else
|
||||||
|
# auto: finalize in-flight prerelease if one exists, else patch
|
||||||
|
if [ -n "$PRE_TAG" ]; then
|
||||||
|
PRE_BASE="${PRE_TAG#v}"
|
||||||
|
PRE_BASE="${PRE_BASE%-pre.*}"
|
||||||
|
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
|
||||||
|
# Refuse to auto-finalize a major bump — it bypasses confirm_major
|
||||||
|
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
|
||||||
|
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# If prerelease base is strictly greater than stable, finalize it
|
||||||
|
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
|
||||||
|
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
|
||||||
|
NEW_VERSION="$PRE_BASE"
|
||||||
|
BUMP="finalize"
|
||||||
|
else
|
||||||
|
PATCH=$((PATCH + 1))
|
||||||
|
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
BUMP="patch"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
PATCH=$((PATCH + 1))
|
||||||
|
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
BUMP="patch"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Bump type: $BUMP"
|
||||||
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||||
|
|
||||||
|
# Update package.json files and Helm chart
|
||||||
|
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||||
|
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||||
|
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||||
|
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||||
|
|
||||||
|
# Commit and tag
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
|
||||||
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||||
|
git tag "v$NEW_VERSION"
|
||||||
|
git push origin main --follow-tags
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
needs: version-bump
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -21,6 +129,8 @@ jobs:
|
|||||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -37,6 +147,8 @@ jobs:
|
|||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||||
no-cache: true
|
no-cache: true
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||||
|
|
||||||
- name: Export digest
|
- name: Export digest
|
||||||
run: |
|
run: |
|
||||||
@@ -54,13 +166,11 @@ jobs:
|
|||||||
|
|
||||||
merge:
|
merge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: [version-bump, build]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
- name: Get version from package.json
|
ref: main
|
||||||
id: version
|
|
||||||
run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Download build digests
|
- name: Download build digests
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -79,13 +189,29 @@ jobs:
|
|||||||
- name: Create and push multi-arch manifest
|
- name: Create and push multi-arch manifest
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
|
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||||
|
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
-t mauriceboe/trek:latest \
|
-t "mauriceboe/trek:latest" \
|
||||||
-t mauriceboe/trek:${{ steps.version.outputs.VERSION }} \
|
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||||
-t mauriceboe/nomad:latest \
|
-t "mauriceboe/trek:$VERSION" \
|
||||||
-t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \
|
|
||||||
"${digests[@]}"
|
"${digests[@]}"
|
||||||
|
|
||||||
- name: Inspect manifest
|
- name: Inspect manifest
|
||||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||||
|
|
||||||
|
release-helm:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: version-bump
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Publish Helm chart
|
||||||
|
uses: stefanprodan/helm-gh-pages@v1.7.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
charts_dir: charts
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
name: Enforce PR Target Branch
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened, edited, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-target:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Flag or clear wrong base branch
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const base = context.payload.pull_request.base.ref;
|
||||||
|
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||||
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
// If the base was fixed, remove the label and let it through
|
||||||
|
if (base !== 'main') {
|
||||||
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base is main — check if this user is a maintainer
|
||||||
|
let permission = 'none';
|
||||||
|
try {
|
||||||
|
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
username: context.payload.pull_request.user.login,
|
||||||
|
});
|
||||||
|
permission = data.permission;
|
||||||
|
} catch (_) {
|
||||||
|
// User is not a collaborator — treat as 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['admin', 'write'].includes(permission)) {
|
||||||
|
console.log(`User has '${permission}' permission, skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already labeled — avoid spamming on every push
|
||||||
|
if (labels.includes('wrong-base-branch')) {
|
||||||
|
core.setFailed("PR must target `dev`, not `main`.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the label exists
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 404) {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
color: 'd73a4a',
|
||||||
|
description: 'PR is targeting the wrong base branch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
labels: ['wrong-base-branch'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body: [
|
||||||
|
'## Wrong target branch',
|
||||||
|
'',
|
||||||
|
'This PR targets `main`, but contributions must go through `dev` first.',
|
||||||
|
'',
|
||||||
|
'To fix this, click **Edit** next to the PR title and change the base branch to `dev`.',
|
||||||
|
'',
|
||||||
|
'**This PR will be automatically closed in 24 hours if the base branch has not been updated.**',
|
||||||
|
'',
|
||||||
|
'> _If you need to merge directly to `main`, contact a maintainer._',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
core.setFailed("PR must target `dev`, not `main`.");
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
paths:
|
||||||
|
- 'server/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
- 'client/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
server-tests:
|
||||||
|
name: Server Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: server/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd server && npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd server && npm run test:coverage
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
if: success()
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: backend-coverage
|
||||||
|
path: server/coverage/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
client-tests:
|
||||||
|
name: Client Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: client/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd client && npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd client && npm run test:coverage
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
if: success()
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: client/coverage/
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Deploy Wiki
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.github/workflows/wiki.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: wiki-deploy
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Publish to GitHub wiki
|
||||||
|
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||||
|
with:
|
||||||
|
strategy: init
|
||||||
+11
-1
@@ -11,9 +11,12 @@ client/public/icons/*.png
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
|
||||||
# User data
|
# User data
|
||||||
server/data/
|
server/data/*
|
||||||
server/uploads/
|
server/uploads/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
@@ -28,6 +31,7 @@ Thumbs.db
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -52,3 +56,9 @@ coverage
|
|||||||
.cache
|
.cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
|
.scannerwork
|
||||||
|
test-data
|
||||||
|
|
||||||
|
.run
|
||||||
|
.full-review
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Contributing to TREK
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! Please read these guidelines before opening a pull request.
|
||||||
|
|
||||||
|
## Ground Rules
|
||||||
|
|
||||||
|
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||||
|
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||||
|
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||||
|
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||||
|
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||||
|
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||||
|
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
### Your PR should include:
|
||||||
|
|
||||||
|
- **Summary** — What does this change and why? (1-3 bullet points)
|
||||||
|
- **Test plan** — How did you verify it works?
|
||||||
|
- **Linked issue** — Reference the issue (e.g. `Fixes #123`)
|
||||||
|
|
||||||
|
### Your PR will be closed if it:
|
||||||
|
|
||||||
|
- Wasn't discussed and approved in `#github-pr` on Discord first
|
||||||
|
- Introduces breaking changes
|
||||||
|
- Adds unnecessary complexity or features beyond scope
|
||||||
|
- Reformats or refactors unrelated code
|
||||||
|
- Adds dependencies without clear justification
|
||||||
|
|
||||||
|
### Commit messages
|
||||||
|
|
||||||
|
Use [conventional commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(maps): correct zoom level on Safari
|
||||||
|
feat(budget): add CSV export for expenses
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||||
|
|
||||||
|
## More Details
|
||||||
|
|
||||||
|
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||||
+14
-14
@@ -1,4 +1,4 @@
|
|||||||
# Stage 1: React Client bauen
|
# Stage 1: Build React client
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:22-alpine AS client-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
COPY client/package*.json ./
|
COPY client/package*.json ./
|
||||||
@@ -6,34 +6,34 @@ RUN npm ci
|
|||||||
COPY client/ ./
|
COPY client/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Produktions-Server
|
# Stage 2: Production server
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN apk add --no-cache python3 make g++ && \
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --production && \
|
||||||
apk del python3 make g++
|
apk del python3 make g++
|
||||||
|
|
||||||
# Server-Code kopieren
|
|
||||||
COPY server/ ./
|
COPY server/ ./
|
||||||
|
|
||||||
# Gebauten Client kopieren
|
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
COPY --from=client-builder /app/client/dist ./public
|
||||||
|
|
||||||
# Fonts für PDF-Export kopieren
|
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||||
|
|
||||||
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
|
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
chown -R node:node /app
|
||||||
|
|
||||||
# Umgebung setzen
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "--import", "tsx", "src/index.ts"]
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
# MCP Integration
|
||||||
|
|
||||||
|
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
|
||||||
|
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
|
||||||
|
structured API.
|
||||||
|
|
||||||
|
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Setup](#setup)
|
||||||
|
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
|
||||||
|
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
|
||||||
|
- [Authentication](#authentication)
|
||||||
|
- [OAuth Scopes](#oauth-scopes)
|
||||||
|
- [Limitations & Important Notes](#limitations--important-notes)
|
||||||
|
- [Resources (read-only)](#resources-read-only)
|
||||||
|
- [Tools (read-write)](#tools-read-write)
|
||||||
|
- [Compound Tools](#compound-tools)
|
||||||
|
- [Prompts](#prompts)
|
||||||
|
- [Example](#example)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Enable the MCP addon (admin)
|
||||||
|
|
||||||
|
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||||
|
endpoint returns `404` and the MCP section does not appear in user settings.
|
||||||
|
|
||||||
|
### 2. Connect your MCP client
|
||||||
|
|
||||||
|
#### Option A: OAuth 2.1 (recommended)
|
||||||
|
|
||||||
|
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
|
||||||
|
management required — just provide the server URL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"trek": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"https://your-trek-instance.com/mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||||
|
|
||||||
|
**What happens automatically:**
|
||||||
|
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
|
||||||
|
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
|
||||||
|
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||||
|
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||||
|
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
|
||||||
|
|
||||||
|
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
||||||
|
> discovery to work correctly.
|
||||||
|
|
||||||
|
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
|
||||||
|
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
|
||||||
|
(`trekcs_` prefix) and fixed scopes that you define up front.
|
||||||
|
|
||||||
|
#### Option B: Static API Token (deprecated)
|
||||||
|
|
||||||
|
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
|
||||||
|
|
||||||
|
1. Go to **Settings > Integrations > MCP** and create an API token.
|
||||||
|
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
|
||||||
|
3. Add it to your `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"trek": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"https://your-trek-instance.com/mcp",
|
||||||
|
"--header",
|
||||||
|
"Authorization: Bearer trek_your_token_here"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
|
||||||
|
static token will receive deprecation warnings in the AI client via server instructions and tool results.
|
||||||
|
|
||||||
|
Each user can create up to **10 static tokens**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
|
||||||
|
|
||||||
|
| Method | Token prefix | Access level | TTL | Notes |
|
||||||
|
|--------|-------------|-------------|-----|-------|
|
||||||
|
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
|
||||||
|
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
|
||||||
|
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
|
||||||
|
|
||||||
|
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Scopes
|
||||||
|
|
||||||
|
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
|
||||||
|
that match your granted scopes for that session.
|
||||||
|
|
||||||
|
| Scope | Permission | Group |
|
||||||
|
|-------|-----------|-------|
|
||||||
|
| `trips:read` | View trips & itineraries | Trips |
|
||||||
|
| `trips:write` | Edit trips & itineraries | Trips |
|
||||||
|
| `trips:delete` | Delete trips (irreversible) | Trips |
|
||||||
|
| `trips:share` | Manage share links | Trips |
|
||||||
|
| `places:read` | View places & map data | Places |
|
||||||
|
| `places:write` | Manage places | Places |
|
||||||
|
| `atlas:read` | View Atlas | Atlas |
|
||||||
|
| `atlas:write` | Manage Atlas | Atlas |
|
||||||
|
| `packing:read` | View packing lists | Packing |
|
||||||
|
| `packing:write` | Manage packing lists | Packing |
|
||||||
|
| `todos:read` | View to-do lists | To-dos |
|
||||||
|
| `todos:write` | Manage to-do lists | To-dos |
|
||||||
|
| `budget:read` | View budget | Budget |
|
||||||
|
| `budget:write` | Manage budget | Budget |
|
||||||
|
| `reservations:read` | View reservations | Reservations |
|
||||||
|
| `reservations:write` | Manage reservations | Reservations |
|
||||||
|
| `collab:read` | View collaboration | Collaboration |
|
||||||
|
| `collab:write` | Manage collaboration | Collaboration |
|
||||||
|
| `notifications:read` | View notifications | Notifications |
|
||||||
|
| `notifications:write` | Manage notifications | Notifications |
|
||||||
|
| `vacay:read` | View vacation plans | Vacation |
|
||||||
|
| `vacay:write` | Manage vacation plans | Vacation |
|
||||||
|
| `geo:read` | Maps & geocoding | Geo |
|
||||||
|
| `weather:read` | Weather forecasts | Weather |
|
||||||
|
| `journey:read` | View journeys | Journey |
|
||||||
|
| `journey:write` | Manage journeys | Journey |
|
||||||
|
| `journey:share` | Manage journey share links | Journey |
|
||||||
|
|
||||||
|
**Scope rules:**
|
||||||
|
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
|
||||||
|
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
|
||||||
|
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
|
||||||
|
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
|
||||||
|
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
|
||||||
|
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limitations & Important Notes
|
||||||
|
|
||||||
|
| Limitation | Details |
|
||||||
|
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
|
||||||
|
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
|
||||||
|
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
|
||||||
|
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
|
||||||
|
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
|
||||||
|
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
|
||||||
|
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
|
||||||
|
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
|
||||||
|
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
|
||||||
|
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
|
||||||
|
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
|
||||||
|
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
|
||||||
|
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||||
|
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources (read-only)
|
||||||
|
|
||||||
|
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
|
||||||
|
making changes.
|
||||||
|
|
||||||
|
### Core Resources
|
||||||
|
|
||||||
|
| Resource | URI | Description |
|
||||||
|
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
|
||||||
|
| Trips | `trek://trips` | All trips you own or are a member of |
|
||||||
|
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
|
||||||
|
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
|
||||||
|
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
|
||||||
|
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
|
||||||
|
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
|
||||||
|
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
|
||||||
|
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
|
||||||
|
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
|
||||||
|
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
|
||||||
|
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
|
||||||
|
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||||
|
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||||
|
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||||
|
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
|
||||||
|
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||||
|
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||||
|
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
|
||||||
|
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
|
||||||
|
|
||||||
|
### Addon-Gated Resources
|
||||||
|
|
||||||
|
These resources are only available when the corresponding addon is enabled by an admin.
|
||||||
|
|
||||||
|
| Resource | URI | Addon | Description |
|
||||||
|
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
|
||||||
|
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
|
||||||
|
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
|
||||||
|
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
|
||||||
|
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
|
||||||
|
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
|
||||||
|
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
|
||||||
|
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
|
||||||
|
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
|
||||||
|
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
|
||||||
|
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
|
||||||
|
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools (read-write)
|
||||||
|
|
||||||
|
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
|
||||||
|
trip in a single call.
|
||||||
|
|
||||||
|
### Trip Summary
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
|
||||||
|
|
||||||
|
### Compound Tools
|
||||||
|
|
||||||
|
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
|
||||||
|
|
||||||
|
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
|
||||||
|
|
||||||
|
| Tool | Wraps | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
|
||||||
|
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
|
||||||
|
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
|
||||||
|
|
||||||
|
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Trips
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------|---------------------------------------------------------------------------------------------|
|
||||||
|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
|
||||||
|
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
|
||||||
|
| `update_trip` | Update a trip's title, description, dates, or currency. |
|
||||||
|
| `delete_trip` | Delete a trip. **Owner only.** |
|
||||||
|
| `list_trip_members` | List the owner and all collaborators of a trip. |
|
||||||
|
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
|
||||||
|
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
|
||||||
|
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
|
||||||
|
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
|
||||||
|
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
|
||||||
|
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
|
||||||
|
| `delete_share_link` | Revoke the public share link for a trip. |
|
||||||
|
|
||||||
|
### Places
|
||||||
|
|
||||||
|
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------------------|--------------------------------------------------------------------------------------------------|
|
||||||
|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
||||||
|
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
||||||
|
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||||
|
| `delete_place` | Remove a place from a trip. |
|
||||||
|
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
|
||||||
|
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
|
||||||
|
| `list_categories` | List all available place categories with id, name, icon and color. |
|
||||||
|
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
||||||
|
|
||||||
|
### Day Planning
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-----------------------------|--------------------------------------------------------------------------------------|
|
||||||
|
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
|
||||||
|
| `create_day` | Add a new day to a trip with optional date and notes. |
|
||||||
|
| `delete_day` | Delete a day from a trip. |
|
||||||
|
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
|
||||||
|
| `unassign_place` | Remove a place assignment from a day. |
|
||||||
|
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
|
||||||
|
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). Pass `null` to clear. |
|
||||||
|
| `move_assignment` | Move a place assignment to a different day. |
|
||||||
|
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
|
||||||
|
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
|
||||||
|
|
||||||
|
### Accommodations
|
||||||
|
|
||||||
|
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------------------------|------------------------------------------------------------------------------------------|
|
||||||
|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
|
||||||
|
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
|
||||||
|
| `delete_accommodation` | Delete an accommodation record from a trip. |
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
|
||||||
|
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
|
||||||
|
| `delete_transport` | Delete a transport booking from a trip. |
|
||||||
|
|
||||||
|
### Reservations
|
||||||
|
|
||||||
|
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||||
|
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||||
|
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||||
|
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
|
||||||
|
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||||
|
|
||||||
|
### Budget
|
||||||
|
|
||||||
|
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------------|---------------------------------------------------------------------------------------|
|
||||||
|
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||||
|
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
|
||||||
|
| `delete_budget_item` | Remove a budget item. |
|
||||||
|
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
|
||||||
|
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
|
||||||
|
|
||||||
|
### Packing
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-------------------------------|-----------------------------------------------------------------------------------|
|
||||||
|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
|
||||||
|
| `update_packing_item` | Rename an item or change its category. |
|
||||||
|
| `toggle_packing_item` | Check or uncheck a packing item. |
|
||||||
|
| `delete_packing_item` | Remove a packing item. |
|
||||||
|
| `reorder_packing_items` | Set the display order of packing items within a trip. |
|
||||||
|
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
|
||||||
|
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
|
||||||
|
| `save_packing_template` | Save the current packing list as a reusable template. |
|
||||||
|
| `list_packing_bags` | List all packing bags for a trip. |
|
||||||
|
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
|
||||||
|
| `update_packing_bag` | Rename or recolor a packing bag. |
|
||||||
|
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
|
||||||
|
| `set_bag_members` | Assign trip members to a packing bag. |
|
||||||
|
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
|
||||||
|
| `set_packing_category_assignees` | Assign trip members to a packing category. |
|
||||||
|
|
||||||
|
### Day Notes
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-------------------|------------------------------------------------------------------------|
|
||||||
|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
|
||||||
|
| `update_day_note` | Edit a day note's text, time, or icon. |
|
||||||
|
| `delete_day_note` | Remove a note from a day. |
|
||||||
|
|
||||||
|
### To-Dos
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-------------------------------|---------------------------------------------------------------------------------------------------|
|
||||||
|
| `list_todos` | List all to-do items for a trip, ordered by position. |
|
||||||
|
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
|
||||||
|
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
|
||||||
|
| `toggle_todo` | Mark a to-do item as done or undone. |
|
||||||
|
| `delete_todo` | Delete a to-do item. |
|
||||||
|
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
|
||||||
|
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
|
||||||
|
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|--------------|--------------------------------------------------------------------------|
|
||||||
|
| `list_tags` | List all tags belonging to the current user. |
|
||||||
|
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
|
||||||
|
| `update_tag` | Update the name or color of an existing tag. |
|
||||||
|
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---------------------------------|------------------------------------------------------|
|
||||||
|
| `list_notifications` | List in-app notifications with pagination and unread filter. |
|
||||||
|
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
|
||||||
|
| `mark_notification_read` | Mark a single notification as read. |
|
||||||
|
| `mark_notification_unread` | Mark a single notification as unread. |
|
||||||
|
| `mark_all_notifications_read` | Mark all notifications as read. |
|
||||||
|
|
||||||
|
### Maps & Weather
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-----------------------|-----------------------------------------------------------------------------------------------------|
|
||||||
|
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
|
||||||
|
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
|
||||||
|
| `reverse_geocode` | Get a human-readable address for given coordinates. |
|
||||||
|
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
|
||||||
|
| `get_weather` | Get weather forecast for a location and date. |
|
||||||
|
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
|
||||||
|
|
||||||
|
### Airports
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
|
||||||
|
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
|
||||||
|
|
||||||
|
### Collab Notes _(Collab addon required)_
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||||
|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
|
||||||
|
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
|
||||||
|
| `delete_collab_note` | Delete a collab note. |
|
||||||
|
|
||||||
|
### Collab Polls & Chat _(Collab addon required)_
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-----------------------|------------------------------------------------------------------------------------------|
|
||||||
|
| `list_collab_polls` | List all polls for a trip. |
|
||||||
|
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
|
||||||
|
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
|
||||||
|
| `close_collab_poll` | Close a poll so no more votes can be cast. |
|
||||||
|
| `delete_collab_poll` | Delete a poll and all its votes. |
|
||||||
|
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
|
||||||
|
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
|
||||||
|
| `delete_collab_message`| Delete a chat message (own messages only). |
|
||||||
|
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
|
||||||
|
|
||||||
|
### Bucket List _(Atlas addon required)_
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||||
|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||||
|
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||||
|
|
||||||
|
### Atlas _(Atlas addon required)_
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|--------------------------|---------------------------------------------------------------------------------|
|
||||||
|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
|
||||||
|
| `unmark_country_visited` | Remove a country from your visited list. |
|
||||||
|
|
||||||
|
### Atlas Extended _(Atlas addon required)_
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------------|------------------------------------------------------------------------------|
|
||||||
|
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
|
||||||
|
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
|
||||||
|
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
|
||||||
|
| `unmark_region_visited` | Remove a region from the visited list. |
|
||||||
|
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
|
||||||
|
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
|
||||||
|
|
||||||
|
### Vacay _(Vacay addon required)_
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|----------------------------|---------------------------------------------------------------------------------------|
|
||||||
|
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
|
||||||
|
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
|
||||||
|
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
|
||||||
|
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
|
||||||
|
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
|
||||||
|
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
|
||||||
|
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
|
||||||
|
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
|
||||||
|
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
|
||||||
|
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
|
||||||
|
| `add_vacay_year` | Add a calendar year to the vacation plan. |
|
||||||
|
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
|
||||||
|
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
|
||||||
|
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
|
||||||
|
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
|
||||||
|
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
|
||||||
|
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
|
||||||
|
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
|
||||||
|
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
|
||||||
|
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
|
||||||
|
| `list_holiday_countries` | List countries available for public holiday calendars. |
|
||||||
|
| `list_holidays` | List public holidays for a country and year. |
|
||||||
|
|
||||||
|
### Journey _(Journey addon required)_
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `list_journeys` | List all journeys owned or contributed to by the current user. |
|
||||||
|
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
|
||||||
|
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
|
||||||
|
| `update_journey` | Update a journey's title, subtitle, or status. |
|
||||||
|
| `delete_journey` | Delete a journey. |
|
||||||
|
| `add_journey_trip` | Link an existing trip to a journey. |
|
||||||
|
| `remove_journey_trip` | Remove a trip from a journey. |
|
||||||
|
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
|
||||||
|
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
|
||||||
|
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
|
||||||
|
| `delete_journey_entry` | Remove an entry from a journey. |
|
||||||
|
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
|
||||||
|
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
|
||||||
|
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
|
||||||
|
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
|
||||||
|
| `remove_journey_contributor` | Remove a contributor from a journey. |
|
||||||
|
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
|
||||||
|
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
|
||||||
|
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
|
||||||
|
| `get_journey_share_link` | Get the current public share link for a journey. |
|
||||||
|
| `create_journey_share_link` | Create or update the public share link for a journey. |
|
||||||
|
| `delete_journey_share_link` | Revoke the public share link for a journey. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompts
|
||||||
|
|
||||||
|
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
|
||||||
|
|
||||||
|
| Prompt | Description |
|
||||||
|
|----------------------|---------------------------------------------------------------------------------|
|
||||||
|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
|
||||||
|
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
|
||||||
|
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
|
||||||
|
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
|
||||||
|
|
||||||
|
Initial prompt (1st message):
|
||||||
|
|
||||||
|
```
|
||||||
|
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
|
||||||
|
and leaving April 11 2027. It's cherry blossom season so please keep that
|
||||||
|
in mind when picking spots.
|
||||||
|
|
||||||
|
Before writing anything to TREK, do some research: look up what's worth
|
||||||
|
visiting, figure out a logical day-by-day flow (group nearby spots together
|
||||||
|
to avoid unnecessary travel), find a well-reviewed hotel in a central
|
||||||
|
neighbourhood, and think about what kind of food and restaurant experiences
|
||||||
|
are worth including.
|
||||||
|
|
||||||
|
Once you have a solid plan, write the whole thing to TREK:
|
||||||
|
- Create the trip
|
||||||
|
- Add all the places you've researched with their real coordinates
|
||||||
|
- Build out the daily itinerary with sensible visiting times
|
||||||
|
- Book the hotel as a reservation and link it properly to the accommodation days
|
||||||
|
- Add any notable restaurant reservations
|
||||||
|
- Put together a realistic budget in EUR
|
||||||
|
- Build a packing list suited to April in Kyoto
|
||||||
|
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
|
||||||
|
- Add a day note for each day with any important heads-up (early start, crowd
|
||||||
|
tips, booking requirements, etc.)
|
||||||
|
- Mark Japan as visited in my Atlas
|
||||||
|
|
||||||
|
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
|
||||||
|
of everything that was added.
|
||||||
|
```
|
||||||
|
|
||||||
|
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
|
||||||
|
|
||||||
|

|
||||||
@@ -1,145 +1,305 @@
|
|||||||
<p align="center">
|
<div align="center">
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
|
||||||
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
|
|
||||||
</picture>
|
|
||||||
<br />
|
|
||||||
<em>Your Trips. Your Plan.</em>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<picture>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||||
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||||
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
|
</picture>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<br />
|
||||||
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
|
||||||
<br />
|
|
||||||
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||

|
<picture>
|
||||||

|
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||||
|
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
|
||||||
|
<br />
|
||||||
|
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||||
|
<br />
|
||||||
|
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||||
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||||
|
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||||
|
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>More Screenshots</summary>
|
<summary><b>See all features</b></summary>
|
||||||
|
|
||||||
| | |
|
<table>
|
||||||
|---|---|
|
<tr>
|
||||||
|  |  |
|
<td width="50%" valign="top">
|
||||||
|  |  |
|
|
||||||
|  | |
|
#### 🧭 Trip planning
|
||||||
|
|
||||||
|
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||||
|
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||||
|
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||||
|
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||||
|
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||||
|
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||||
|
- **Category filter** — show only matching pins on the map
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🧳 Travel management
|
||||||
|
|
||||||
|
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
|
||||||
|
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||||
|
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||||
|
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||||
|
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||||
|
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 👥 Collaboration
|
||||||
|
|
||||||
|
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
|
||||||
|
- **Multi-user trips** — invite members with role-based access
|
||||||
|
- **Invite links** — one-time or reusable links with expiry
|
||||||
|
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
|
- **2FA** — TOTP + backup codes
|
||||||
|
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 📱 Mobile & PWA
|
||||||
|
|
||||||
|
- **Installable** — iOS and Android, straight from the browser, no App Store needed
|
||||||
|
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
|
||||||
|
- **Native feel** — fullscreen standalone, themed status bar, splash screen
|
||||||
|
- **Touch optimised** — mobile-specific layouts with safe-area handling
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🧩 Addons (admin-toggleable)
|
||||||
|
|
||||||
|
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||||
|
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||||
|
- **Documents** — file attachments on trips, places, and reservations
|
||||||
|
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||||
|
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||||
|
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||||
|
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||||
|
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||||
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🤖 AI / MCP
|
||||||
|
|
||||||
|
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||||
|
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||||
|
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||||
|
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||||
|
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" valign="top">
|
||||||
|
|
||||||
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
|
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||||
|
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||||
|
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||||
|
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Features
|
<br />
|
||||||
|
|
||||||
### Trip Planning
|
## Get started in 30 seconds
|
||||||
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
|
||||||
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
|
|
||||||
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
|
|
||||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
|
||||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
|
||||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
|
||||||
|
|
||||||
### Travel Management
|
|
||||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
|
||||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
|
||||||
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
|
||||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
|
||||||
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
|
|
||||||
|
|
||||||
### Mobile & PWA
|
|
||||||
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
|
||||||
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
|
|
||||||
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
|
|
||||||
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
|
|
||||||
|
|
||||||
### Collaboration
|
|
||||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
|
||||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
|
||||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
|
||||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
|
||||||
|
|
||||||
### Addons (modular, admin-toggleable)
|
|
||||||
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
|
||||||
- **Atlas** — Interactive world map with visited countries, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
|
||||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
|
||||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
|
||||||
|
|
||||||
### Customization & Admin
|
|
||||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
|
||||||
- **Multilingual** — English, German, Chinese (Simplified), Dutch, Russian (i18n)
|
|
||||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
|
||||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
|
||||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
|
|
||||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
|
||||||
- **PWA**: vite-plugin-pwa + Workbox
|
|
||||||
- **Real-Time**: WebSocket (`ws`)
|
|
||||||
- **State**: Zustand
|
|
||||||
- **Auth**: JWT + OIDC
|
|
||||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
|
||||||
- **Weather**: Open-Meteo API (free, no key required)
|
|
||||||
- **Icons**: lucide-react
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||||
|
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||||
|
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs on port `3000`. The first user to register becomes the admin.
|
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||||
|
|
||||||
### Install as App (PWA)
|
<div align="center">
|
||||||
|
|
||||||
TREK works as a Progressive Web App — no App Store needed:
|
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||||
|
|
||||||
1. Open your TREK instance in the browser (HTTPS required)
|
</div>
|
||||||
2. **iOS**: Share button → "Add to Home Screen"
|
|
||||||
3. **Android**: Menu → "Install app" or "Add to Home Screen"
|
<br />
|
||||||
4. TREK launches fullscreen with its own icon, just like a native app
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<h2 id="docker-compose-production">Docker Compose (production)</h2>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Docker Compose (recommended for production)</summary>
|
<summary>Full compose example with secure defaults</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/trek:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: trek
|
container_name: trek
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- CHOWN
|
||||||
|
- SETUID
|
||||||
|
- SETGID
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:noexec,nosuid,size=64m
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||||
|
- APP_URL=${APP_URL:-} # required for OIDC + email links
|
||||||
|
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
|
||||||
|
# - TRUST_PROXY=1
|
||||||
|
# - OIDC_ISSUER=https://auth.example.com
|
||||||
|
# - OIDC_CLIENT_ID=trek
|
||||||
|
# - OIDC_CLIENT_SECRET=supersecret
|
||||||
|
# - OIDC_DISPLAY_NAME=SSO
|
||||||
|
# - OIDC_ADMIN_CLAIM=groups
|
||||||
|
# - OIDC_ADMIN_VALUE=app-trek-admins
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Updating
|
<br />
|
||||||
|
|
||||||
**Docker Compose** (recommended):
|
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add trek https://mauriceboe.github.io/TREK
|
||||||
|
helm repo update
|
||||||
|
helm install trek trek/trek
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||||
|
|
||||||
|
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||||
|
|
||||||
|
TREK works as a Progressive Web App — no App Store needed.
|
||||||
|
|
||||||
|
1. Open TREK in the browser (HTTPS required)
|
||||||
|
2. **iOS**: Share ▸ *Add to Home Screen*
|
||||||
|
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
|
||||||
|
|
||||||
|
TREK then launches fullscreen with its own icon, just like a native app.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
**Docker Compose:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
**Docker run** — reuse the original volume paths:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull mauriceboe/trek
|
docker pull mauriceboe/trek
|
||||||
@@ -147,15 +307,23 @@ docker rm -f trek
|
|||||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||||
|
|
||||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||||
|
|
||||||
### Reverse Proxy (recommended)
|
<h3>Rotating the Encryption Key</h3>
|
||||||
|
|
||||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||||
|
|
||||||
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
```bash
|
||||||
|
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
|
||||||
|
|
||||||
|
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||||
|
|
||||||
|
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Nginx</summary>
|
<summary>Nginx</summary>
|
||||||
@@ -163,16 +331,28 @@ For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, T
|
|||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name nomad.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name nomad.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
|
|
||||||
ssl_certificate /path/to/fullchain.pem;
|
ssl_certificate /etc/ssl/fullchain.pem;
|
||||||
ssl_certificate_key /path/to/privkey.pem;
|
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||||
|
|
||||||
|
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
|
||||||
|
client_max_body_size 500m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location /ws {
|
location /ws {
|
||||||
proxy_pass http://localhost:3000;
|
proxy_pass http://localhost:3000;
|
||||||
@@ -180,19 +360,8 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 86400;
|
proxy_read_timeout 86400;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -201,42 +370,73 @@ server {
|
|||||||
<details>
|
<details>
|
||||||
<summary>Caddy</summary>
|
<summary>Caddy</summary>
|
||||||
|
|
||||||
Caddy handles WebSocket upgrades automatically:
|
```caddy
|
||||||
|
trek.yourdomain.com {
|
||||||
```
|
|
||||||
nomad.yourdomain.com {
|
|
||||||
reverse_proxy localhost:3000
|
reverse_proxy localhost:3000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Caddy handles TLS and WebSockets automatically.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Optional API Keys
|
<br />
|
||||||
|
|
||||||
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
## Environment variables
|
||||||
|
|
||||||
### Google Maps (Place Search & Photos)
|
<details>
|
||||||
|
<summary><b>Full reference</b></summary>
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
<br />
|
||||||
2. Create a project and enable the **Places API (New)**
|
|
||||||
3. Create an API key under Credentials
|
|
||||||
4. In TREK: Admin Panel → Settings → Google Maps
|
|
||||||
|
|
||||||
## Building from Source
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| **Core** | | |
|
||||||
|
| `PORT` | Server port | `3000` |
|
||||||
|
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||||
|
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||||
|
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||||
|
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||||
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||||
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
|
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||||
|
| **OIDC / SSO** | | |
|
||||||
|
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||||
|
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||||
|
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||||
|
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||||
|
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
|
||||||
|
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||||
|
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||||
|
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
|
||||||
|
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
|
||||||
|
| **Initial setup** | | |
|
||||||
|
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||||
|
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||||
|
| **Other** | | |
|
||||||
|
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||||
|
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||||
|
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||||
|
|
||||||
```bash
|
</details>
|
||||||
git clone https://github.com/mauriceboe/TREK.git
|
|
||||||
cd NOMAD
|
<br />
|
||||||
docker build -t trek .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data & Backups
|
## Data & Backups
|
||||||
|
|
||||||
- **Database**: SQLite, stored in `./data/travel.db`
|
- **Database** — SQLite, stored in `./data/travel.db`
|
||||||
- **Uploads**: Stored in `./uploads/`
|
- **Uploads** — stored in `./uploads/`
|
||||||
- **Backups**: Create and restore via Admin Panel
|
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
- **Backups** — create and restore via Admin Panel
|
||||||
|
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE)
|
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This policy covers the TREK application and its Docker image (`mauriceboe/nomad`).
|
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||||
|
|
||||||
Third-party dependencies are monitored via GitHub Dependabot.
|
Third-party dependencies are monitored via GitHub Dependabot.
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# TREK Helm Chart
|
||||||
|
|
||||||
|
This is a minimal Helm chart for deploying the TREK app.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Deploys the TREK container
|
||||||
|
- Exposes port 3000 via Service
|
||||||
|
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||||
|
- Configurable environment variables and secrets
|
||||||
|
- Optional generic Ingress support
|
||||||
|
- Health checks on `/api/health`
|
||||||
|
|
||||||
|
## Helm Repository
|
||||||
|
|
||||||
|
A hosted Helm repository is available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
helm repo add trek https://mauriceboe.github.io/TREK
|
||||||
|
helm repo update
|
||||||
|
helm install trek trek/trek
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Or install directly from the local chart:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
helm install trek ./chart \
|
||||||
|
--set ingress.enabled=true \
|
||||||
|
--set ingress.hosts[0].host=yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
See `values.yaml` for more options.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `Chart.yaml` — chart metadata
|
||||||
|
- `values.yaml` — configuration values
|
||||||
|
- `templates/` — Kubernetes manifests
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||||
|
- PVCs require a default StorageClass or specify one as needed.
|
||||||
|
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||||
|
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||||
|
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||||
|
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||||
|
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||||
|
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
|
||||||
|
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||||
|
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: trek
|
||||||
|
version: 3.0.0
|
||||||
|
description: Minimal Helm chart for TREK app
|
||||||
|
appVersion: "3.0.0"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
1. ENCRYPTION_KEY handling:
|
||||||
|
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
|
||||||
|
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
|
||||||
|
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
|
||||||
|
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
|
||||||
|
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
|
||||||
|
- If left empty, the server resolves the key automatically: existing installs fall back to
|
||||||
|
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
|
||||||
|
auto-generate a key persisted to the data PVC.
|
||||||
|
|
||||||
|
2. JWT_SECRET is managed entirely by the server:
|
||||||
|
- Auto-generated on first start and persisted to the data PVC (data/.jwt_secret).
|
||||||
|
- Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret).
|
||||||
|
- No Helm configuration needed or supported.
|
||||||
|
|
||||||
|
3. Example usage:
|
||||||
|
- Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key`
|
||||||
|
- Generate a random key at install: `--set generateEncryptionKey=true`
|
||||||
|
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
||||||
|
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
|
||||||
|
|
||||||
|
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
|
||||||
|
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "trek.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
*/}}
|
||||||
|
{{- define "trek.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride -}}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||||
|
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-config
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
data:
|
||||||
|
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
|
||||||
|
PORT: {{ .Values.env.PORT | quote }}
|
||||||
|
{{- if .Values.env.TZ }}
|
||||||
|
TZ: {{ .Values.env.TZ | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.LOG_LEVEL }}
|
||||||
|
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||||
|
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.APP_URL }}
|
||||||
|
APP_URL: {{ .Values.env.APP_URL | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.FORCE_HTTPS }}
|
||||||
|
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.TRUST_PROXY }}
|
||||||
|
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||||
|
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OIDC_ISSUER }}
|
||||||
|
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OIDC_CLIENT_ID }}
|
||||||
|
OIDC_CLIENT_ID: {{ .Values.env.OIDC_CLIENT_ID | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OIDC_DISPLAY_NAME }}
|
||||||
|
OIDC_DISPLAY_NAME: {{ .Values.env.OIDC_DISPLAY_NAME | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OIDC_ONLY }}
|
||||||
|
OIDC_ONLY: {{ .Values.env.OIDC_ONLY | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OIDC_ADMIN_CLAIM }}
|
||||||
|
OIDC_ADMIN_CLAIM: {{ .Values.env.OIDC_ADMIN_CLAIM | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OIDC_ADMIN_VALUE }}
|
||||||
|
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OIDC_SCOPE }}
|
||||||
|
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OIDC_DISCOVERY_URL }}
|
||||||
|
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.DEMO_MODE }}
|
||||||
|
DEMO_MODE: {{ .Values.env.DEMO_MODE | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||||
|
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||||
|
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- range .Values.imagePullSecrets }}
|
||||||
|
- name: {{ .name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 1000
|
||||||
|
containers:
|
||||||
|
- name: trek
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
{{- with .Values.resources }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: {{ include "trek.fullname" . }}-config
|
||||||
|
env:
|
||||||
|
- name: ENCRYPTION_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||||
|
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
|
||||||
|
optional: true
|
||||||
|
- name: ADMIN_EMAIL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||||
|
key: ADMIN_EMAIL
|
||||||
|
optional: true
|
||||||
|
- name: ADMIN_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||||
|
key: ADMIN_PASSWORD
|
||||||
|
optional: true
|
||||||
|
- name: OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||||
|
key: OIDC_CLIENT_SECRET
|
||||||
|
optional: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /app/data
|
||||||
|
- name: uploads
|
||||||
|
mountPath: /app/uploads
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "trek.fullname" . }}-data
|
||||||
|
- name: uploads
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "trek.fullname" . }}-uploads
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ . }}
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "trek.fullname" $ }}
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-data
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.data.size }}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-uploads
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-secret
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
|
||||||
|
{{- if .Values.secretEnv.ADMIN_EMAIL }}
|
||||||
|
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||||
|
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||||
|
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||||
|
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
|
||||||
|
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ $secretName }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
{{- if and $existingSecret $existingSecret.data }}
|
||||||
|
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
|
||||||
|
{{- else }}
|
||||||
|
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.secretEnv.ADMIN_EMAIL }}
|
||||||
|
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||||
|
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||||
|
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
|
||||||
|
image:
|
||||||
|
repository: mauriceboe/trek
|
||||||
|
# tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Optional image pull secrets for private registries
|
||||||
|
imagePullSecrets: []
|
||||||
|
# - name: my-registry-secret
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 3000
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
# TZ: "UTC"
|
||||||
|
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||||
|
# LOG_LEVEL: "info"
|
||||||
|
# "info" = concise user actions, "debug" = verbose details.
|
||||||
|
# DEFAULT_LANGUAGE: "en"
|
||||||
|
# Default language on the login page for users with no saved preference.
|
||||||
|
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
|
||||||
|
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
|
# ALLOWED_ORIGINS: ""
|
||||||
|
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||||
|
# APP_URL: "https://trek.example.com"
|
||||||
|
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
||||||
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
|
# FORCE_HTTPS: "false"
|
||||||
|
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||||
|
# COOKIE_SECURE: "true"
|
||||||
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
|
# TRUST_PROXY: "1"
|
||||||
|
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||||
|
# ALLOW_INTERNAL_NETWORK: "false"
|
||||||
|
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||||
|
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||||
|
# OIDC_ISSUER: ""
|
||||||
|
# OpenID Connect provider URL.
|
||||||
|
# OIDC_CLIENT_ID: ""
|
||||||
|
# OIDC client ID.
|
||||||
|
# OIDC_DISPLAY_NAME: "SSO"
|
||||||
|
# Label shown on the SSO login button.
|
||||||
|
# OIDC_ONLY: "false"
|
||||||
|
# Set to "true" to force SSO-only mode: disables password login and password registration.
|
||||||
|
# Overrides the granular toggles in Admin > Settings and cannot be changed at runtime.
|
||||||
|
# First SSO login becomes admin on a fresh instance.
|
||||||
|
# OIDC_ADMIN_CLAIM: ""
|
||||||
|
# OIDC claim used to identify admin users.
|
||||||
|
# OIDC_ADMIN_VALUE: ""
|
||||||
|
# Value of the OIDC claim that grants admin role.
|
||||||
|
# OIDC_SCOPE: "openid email profile groups"
|
||||||
|
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
|
||||||
|
# OIDC_DISCOVERY_URL: ""
|
||||||
|
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||||
|
# DEMO_MODE: "false"
|
||||||
|
# Enable demo mode (hourly data resets).
|
||||||
|
# MCP_RATE_LIMIT: "300"
|
||||||
|
# Max MCP API requests per user per minute. Defaults to 300.
|
||||||
|
# MCP_MAX_SESSION_PER_USER: "20"
|
||||||
|
# Max concurrent MCP sessions per user. Defaults to 20.
|
||||||
|
|
||||||
|
|
||||||
|
# Secret environment variables stored in a Kubernetes Secret.
|
||||||
|
# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC,
|
||||||
|
# rotatable via the admin panel) — it is not configured here.
|
||||||
|
secretEnv:
|
||||||
|
# At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.).
|
||||||
|
# Recommended: set to a random 32-byte hex value (openssl rand -hex 32).
|
||||||
|
# If left empty the server resolves the key automatically:
|
||||||
|
# 1. data/.jwt_secret (existing installs — encrypted data stays readable after upgrade)
|
||||||
|
# 2. data/.encryption_key auto-generated on first start (fresh installs)
|
||||||
|
ENCRYPTION_KEY: ""
|
||||||
|
# Initial admin account — only used on first boot when no users exist yet.
|
||||||
|
# If both values are non-empty the admin account is created with these credentials.
|
||||||
|
# If either is empty a random password is generated and printed to the server log.
|
||||||
|
ADMIN_EMAIL: ""
|
||||||
|
ADMIN_PASSWORD: ""
|
||||||
|
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
|
||||||
|
OIDC_CLIENT_SECRET: ""
|
||||||
|
|
||||||
|
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||||
|
generateEncryptionKey: false
|
||||||
|
|
||||||
|
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
|
||||||
|
existingSecret: ""
|
||||||
|
existingSecretKey: ENCRYPTION_KEY
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
data:
|
||||||
|
size: 1Gi
|
||||||
|
uploads:
|
||||||
|
size: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
annotations: {}
|
||||||
|
hosts:
|
||||||
|
- host: chart-example.local
|
||||||
|
paths:
|
||||||
|
- /
|
||||||
|
tls: []
|
||||||
|
# - secretName: chart-example-tls
|
||||||
|
# hosts:
|
||||||
|
# - chart-example.local
|
||||||
+4
-2
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>TREK</title>
|
<title>TREK</title>
|
||||||
|
|
||||||
<!-- PWA / iOS -->
|
<!-- PWA / iOS -->
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Leaflet -->
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin="" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+3038
-350
File diff suppressed because it is too large
Load Diff
+21
-3
@@ -1,19 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.7.0",
|
"version": "3.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"prebuild": "node scripts/generate-icons.mjs",
|
"prebuild": "node scripts/generate-icons.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:unit": "vitest run tests/unit",
|
||||||
|
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"dexie": "^4.4.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
|
"mapbox-gl": "^3.22.0",
|
||||||
|
"marked": "^18.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
@@ -22,22 +30,32 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.22.2",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/leaflet": "^1.9.8",
|
"@types/leaflet": "^1.9.8",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^18.2.61",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.19",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"msw": "^2.13.0",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"sharp": "^0.33.0",
|
"sharp": "^0.33.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.1.4",
|
||||||
"vite-plugin-pwa": "^0.21.0"
|
"vite-plugin-pwa": "^0.21.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -0,0 +1,322 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { server } from '../tests/helpers/msw/server'
|
||||||
|
import { useAuthStore } from './store/authStore'
|
||||||
|
import { useSettingsStore } from './store/settingsStore'
|
||||||
|
import { resetAllStores } from '../tests/helpers/store'
|
||||||
|
import { buildUser, buildSettings } from '../tests/helpers/factories'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
// ── Mock page components ───────────────────────────────────────────────────────
|
||||||
|
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
|
||||||
|
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
|
||||||
|
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>TripPlanner</div> }))
|
||||||
|
vi.mock('./pages/FilesPage', () => ({ default: () => <div>Files</div> }))
|
||||||
|
vi.mock('./pages/AdminPage', () => ({ default: () => <div>Admin</div> }))
|
||||||
|
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings</div> }))
|
||||||
|
vi.mock('./pages/VacayPage', () => ({ default: () => <div>Vacay</div> }))
|
||||||
|
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>Atlas</div> }))
|
||||||
|
vi.mock('./pages/SharedTripPage', () => ({ default: () => <div>SharedTrip</div> }))
|
||||||
|
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>Notifications</div> }))
|
||||||
|
|
||||||
|
// Prevent WebSocket side effects from the notification listener
|
||||||
|
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
|
||||||
|
useInAppNotificationListener: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderApp(initialPath = '/') {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds authStore with sensible defaults for a test, replacing loadUser with a
|
||||||
|
* no-op spy so the MSW /api/auth/me response does not overwrite the seeded state.
|
||||||
|
*/
|
||||||
|
function seedAuth(overrides: Record<string, unknown> = {}) {
|
||||||
|
useAuthStore.setState({
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
appRequireMfa: false,
|
||||||
|
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── RootRedirect ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('RootRedirect', () => {
|
||||||
|
it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => {
|
||||||
|
seedAuth({ isAuthenticated: false })
|
||||||
|
renderApp('/')
|
||||||
|
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => {
|
||||||
|
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||||
|
renderApp('/')
|
||||||
|
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => {
|
||||||
|
seedAuth({ isLoading: true, isAuthenticated: false })
|
||||||
|
renderApp('/')
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Login')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProtectedRoute — unauthenticated', () => {
|
||||||
|
it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => {
|
||||||
|
seedAuth({ isAuthenticated: false })
|
||||||
|
renderApp('/dashboard')
|
||||||
|
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
|
||||||
|
seedAuth({ isAuthenticated: false })
|
||||||
|
renderApp('/trips/42')
|
||||||
|
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ProtectedRoute — loading ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProtectedRoute — loading state', () => {
|
||||||
|
it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => {
|
||||||
|
seedAuth({ isLoading: true, isAuthenticated: false })
|
||||||
|
renderApp('/dashboard')
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ProtectedRoute — MFA enforcement ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProtectedRoute — MFA enforcement', () => {
|
||||||
|
it('FE-COMP-APP-007: redirects to /settings?mfa=required when appRequireMfa is true and MFA is disabled', async () => {
|
||||||
|
seedAuth({
|
||||||
|
isAuthenticated: true,
|
||||||
|
appRequireMfa: true,
|
||||||
|
user: buildUser({ mfa_enabled: false }),
|
||||||
|
})
|
||||||
|
renderApp('/dashboard')
|
||||||
|
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => {
|
||||||
|
seedAuth({
|
||||||
|
isAuthenticated: true,
|
||||||
|
appRequireMfa: true,
|
||||||
|
user: buildUser({ mfa_enabled: false }),
|
||||||
|
})
|
||||||
|
renderApp('/settings')
|
||||||
|
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByText('Login')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => {
|
||||||
|
seedAuth({
|
||||||
|
isAuthenticated: true,
|
||||||
|
appRequireMfa: true,
|
||||||
|
user: buildUser({ mfa_enabled: true }),
|
||||||
|
})
|
||||||
|
renderApp('/dashboard')
|
||||||
|
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ProtectedRoute — admin role ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProtectedRoute — admin role check', () => {
|
||||||
|
it('FE-COMP-APP-010: /admin redirects to /dashboard for non-admin user', async () => {
|
||||||
|
seedAuth({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: buildUser({ role: 'user' }),
|
||||||
|
})
|
||||||
|
renderApp('/admin')
|
||||||
|
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-011: /admin is accessible for admin user', async () => {
|
||||||
|
seedAuth({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: buildUser({ role: 'admin' }),
|
||||||
|
})
|
||||||
|
renderApp('/admin')
|
||||||
|
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Public routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Public routes', () => {
|
||||||
|
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
|
||||||
|
seedAuth({ isAuthenticated: false })
|
||||||
|
renderApp('/login')
|
||||||
|
expect(screen.getByText('Login')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
|
||||||
|
seedAuth({ isAuthenticated: false })
|
||||||
|
renderApp('/shared/sometoken')
|
||||||
|
expect(screen.getByText('SharedTrip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
|
||||||
|
seedAuth({ isAuthenticated: false })
|
||||||
|
renderApp('/does-not-exist')
|
||||||
|
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── App — on-mount effects ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('App — on-mount effects', () => {
|
||||||
|
it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => {
|
||||||
|
const loadUser = vi.fn().mockResolvedValue(undefined)
|
||||||
|
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
|
||||||
|
renderApp('/dashboard')
|
||||||
|
expect(loadUser).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => {
|
||||||
|
const loadUser = vi.fn().mockResolvedValue(undefined)
|
||||||
|
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
|
||||||
|
renderApp('/shared/token123')
|
||||||
|
expect(loadUser).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => {
|
||||||
|
let configCalled = false
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/app-config', () => {
|
||||||
|
configCalled = true
|
||||||
|
return HttpResponse.json({})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
seedAuth()
|
||||||
|
renderApp('/')
|
||||||
|
await waitFor(() => expect(configCalled).toBe(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true }))
|
||||||
|
)
|
||||||
|
const setDemoMode = vi.fn()
|
||||||
|
useAuthStore.setState({
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setDemoMode,
|
||||||
|
})
|
||||||
|
renderApp('/')
|
||||||
|
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
|
||||||
|
const loadSettings = vi.fn().mockResolvedValue(undefined)
|
||||||
|
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||||
|
useSettingsStore.setState({ loadSettings })
|
||||||
|
renderApp('/dashboard')
|
||||||
|
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Dark mode effects ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Dark mode effects', () => {
|
||||||
|
it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => {
|
||||||
|
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||||
|
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
|
||||||
|
renderApp('/dashboard')
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||||
|
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) })
|
||||||
|
renderApp('/dashboard')
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
|
||||||
|
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) })
|
||||||
|
renderApp('/shared/tok')
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => {
|
||||||
|
// matchMedia stub returns matches: false by default (from setup.ts)
|
||||||
|
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||||
|
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) })
|
||||||
|
renderApp('/dashboard')
|
||||||
|
// With matches: false, dark should NOT be added
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Version cache-busting ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Version cache-busting', () => {
|
||||||
|
it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/app-config', () =>
|
||||||
|
HttpResponse.json({ version: '2.9.10' })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seedAuth()
|
||||||
|
renderApp('/')
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => {
|
||||||
|
localStorage.setItem('trek_app_version', '2.9.9')
|
||||||
|
const reload = vi.fn()
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
writable: true,
|
||||||
|
value: { ...window.location, reload },
|
||||||
|
})
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/app-config', () =>
|
||||||
|
HttpResponse.json({ version: '2.9.10' })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seedAuth()
|
||||||
|
renderApp('/')
|
||||||
|
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
})
|
||||||
+146
-11
@@ -2,8 +2,10 @@ import React, { useEffect, ReactNode } from 'react'
|
|||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from './store/authStore'
|
import { useAuthStore } from './store/authStore'
|
||||||
import { useSettingsStore } from './store/settingsStore'
|
import { useSettingsStore } from './store/settingsStore'
|
||||||
|
import { useAddonStore } from './store/addonStore'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import RegisterPage from './pages/RegisterPage'
|
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||||
|
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import TripPlannerPage from './pages/TripPlannerPage'
|
import TripPlannerPage from './pages/TripPlannerPage'
|
||||||
import FilesPage from './pages/FilesPage'
|
import FilesPage from './pages/FilesPage'
|
||||||
@@ -11,19 +13,38 @@ import AdminPage from './pages/AdminPage'
|
|||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import VacayPage from './pages/VacayPage'
|
import VacayPage from './pages/VacayPage'
|
||||||
import AtlasPage from './pages/AtlasPage'
|
import AtlasPage from './pages/AtlasPage'
|
||||||
|
import JourneyPage from './pages/JourneyPage'
|
||||||
|
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||||
|
import JourneyPublicPage from './pages/JourneyPublicPage'
|
||||||
|
import SharedTripPage from './pages/SharedTripPage'
|
||||||
|
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||||
|
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||||
import { ToastContainer } from './components/shared/Toast'
|
import { ToastContainer } from './components/shared/Toast'
|
||||||
|
import BottomNav from './components/Layout/BottomNav'
|
||||||
import { TranslationProvider, useTranslation } from './i18n'
|
import { TranslationProvider, useTranslation } from './i18n'
|
||||||
import DemoBanner from './components/Layout/DemoBanner'
|
|
||||||
import { authApi } from './api/client'
|
import { authApi } from './api/client'
|
||||||
|
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||||
|
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||||
|
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||||
|
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||||
|
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||||
|
// Notice action registrations (side-effect imports):
|
||||||
|
import './pages/Trips/noticeActions.js'
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
adminRequired?: boolean
|
adminRequired?: boolean
|
||||||
|
addonId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
|
||||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const isLoading = useAuthStore((s) => s.isLoading)
|
||||||
|
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||||
|
const addonStore = useAddonStore()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -37,14 +58,33 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Navigate to="/login" replace />
|
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
||||||
|
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appRequireMfa &&
|
||||||
|
user &&
|
||||||
|
!user.mfa_enabled &&
|
||||||
|
location.pathname !== '/settings'
|
||||||
|
) {
|
||||||
|
return <Navigate to="/settings?mfa=required" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminRequired && user && user.role !== 'admin') {
|
if (adminRequired && user && user.role !== 'admin') {
|
||||||
return <Navigate to="/dashboard" replace />
|
return <Navigate to="/dashboard" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
|
||||||
|
return <Navigate to="/dashboard" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen md:block md:h-auto">
|
||||||
|
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
|
||||||
|
<BottomNav />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
@@ -62,28 +102,86 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
|
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
const { loadAddons } = useAddonStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||||
loadUser()
|
// If the persist snapshot already has an authenticated user, validate
|
||||||
|
// silently so the PWA shell renders immediately without a spinner.
|
||||||
|
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
|
||||||
|
if (alreadyAuthenticated) {
|
||||||
|
useAuthStore.setState({ isLoading: false })
|
||||||
|
loadUser({ silent: true })
|
||||||
|
} else {
|
||||||
|
loadUser()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then((config: { demo_mode?: boolean; has_maps_key?: boolean }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
|
if (config?.dev_mode) setDevMode(true)
|
||||||
|
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||||
|
if (config?.version) setAppVersion(config.version)
|
||||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
|
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||||
|
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||||
|
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
||||||
|
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||||
|
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||||
|
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||||
|
|
||||||
|
if (config?.version) {
|
||||||
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
|
if (storedVersion && storedVersion !== config.version) {
|
||||||
|
try {
|
||||||
|
if ('caches' in window) {
|
||||||
|
const names = await caches.keys()
|
||||||
|
await Promise.all(names.map(n => caches.delete(n)))
|
||||||
|
}
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(regs.map(r => r.unregister()))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
localStorage.setItem('trek_app_version', config.version)
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem('trek_app_version', config.version)
|
||||||
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { settings } = useSettingsStore()
|
const { settings } = useSettingsStore()
|
||||||
|
|
||||||
|
useInAppNotificationListener()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
loadSettings()
|
loadSettings()
|
||||||
|
loadAddons()
|
||||||
}
|
}
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
registerSyncTriggers()
|
||||||
|
return () => unregisterSyncTriggers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const location = useLocation()
|
||||||
|
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Shared page always forces light mode
|
||||||
|
if (isSharedPage) {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) meta.setAttribute('content', '#ffffff')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const mode = settings.dark_mode
|
const mode = settings.dark_mode
|
||||||
const applyDark = (isDark: boolean) => {
|
const applyDark = (isDark: boolean) => {
|
||||||
document.documentElement.classList.toggle('dark', isDark)
|
document.documentElement.classList.toggle('dark', isDark)
|
||||||
@@ -99,15 +197,28 @@ export default function App() {
|
|||||||
return () => mq.removeEventListener('change', handler)
|
return () => mq.removeEventListener('change', handler)
|
||||||
}
|
}
|
||||||
applyDark(mode === true || mode === 'dark')
|
applyDark(mode === true || mode === 'dark')
|
||||||
}, [settings.dark_mode])
|
}, [settings.dark_mode, isSharedPage])
|
||||||
|
|
||||||
|
const isAuthPage = location.pathname.startsWith('/login')
|
||||||
|
|| location.pathname.startsWith('/register')
|
||||||
|
|| location.pathname.startsWith('/forgot-password')
|
||||||
|
|| location.pathname.startsWith('/reset-password')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
|
{!isAuthPage && <SystemNoticeHost />}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
<OfflineBanner />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RootRedirect />} />
|
<Route path="/" element={<RootRedirect />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||||
|
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||||
<Route path="/register" element={<LoginPage />} />
|
<Route path="/register" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||||
|
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
@@ -164,6 +275,30 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/journey"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute addonId="journey">
|
||||||
|
<JourneyPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/journey/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute addonId="journey">
|
||||||
|
<JourneyDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/notifications"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<InAppNotificationsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export async function getAuthUrl(url: string, purpose: 'download'): Promise<string> {
|
||||||
|
if (!url) return url
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/resource-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ purpose }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) return url
|
||||||
|
const { token } = await resp.json()
|
||||||
|
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 6
|
||||||
|
let active = 0
|
||||||
|
const queue: Array<() => void> = []
|
||||||
|
|
||||||
|
function dequeue() {
|
||||||
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||||
|
active++
|
||||||
|
queue.shift()!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearImageQueue() {
|
||||||
|
queue.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImageAsBlob(url: string): Promise<string> {
|
||||||
|
if (!url) return ''
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { credentials: 'include' })
|
||||||
|
if (!resp.ok) { resolve(''); return }
|
||||||
|
const blob = await resp.blob()
|
||||||
|
resolve(URL.createObjectURL(blob))
|
||||||
|
} catch {
|
||||||
|
resolve('')
|
||||||
|
} finally {
|
||||||
|
active--
|
||||||
|
dequeue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active < MAX_CONCURRENT) {
|
||||||
|
active++
|
||||||
|
run()
|
||||||
|
} else {
|
||||||
|
queue.push(run)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+283
-15
@@ -1,39 +1,101 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
|
import en from '../i18n/translations/en'
|
||||||
|
import br from '../i18n/translations/br'
|
||||||
|
import de from '../i18n/translations/de'
|
||||||
|
import es from '../i18n/translations/es'
|
||||||
|
import fr from '../i18n/translations/fr'
|
||||||
|
import it from '../i18n/translations/it'
|
||||||
|
import nl from '../i18n/translations/nl'
|
||||||
|
import pl from '../i18n/translations/pl'
|
||||||
|
import cs from '../i18n/translations/cs'
|
||||||
|
import hu from '../i18n/translations/hu'
|
||||||
|
import ru from '../i18n/translations/ru'
|
||||||
|
import zh from '../i18n/translations/zh'
|
||||||
|
import zhTw from '../i18n/translations/zhTw'
|
||||||
|
import ar from '../i18n/translations/ar'
|
||||||
|
|
||||||
const apiClient: AxiosInstance = axios.create({
|
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
|
||||||
|
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateRateLimit(): string {
|
||||||
|
const fallback = 'Too many attempts. Please try again later.'
|
||||||
|
try {
|
||||||
|
const lang = localStorage.getItem('app_language') || 'en'
|
||||||
|
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
|
||||||
|
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request interceptor - add auth token and socket ID
|
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
||||||
|
|
||||||
|
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
const sid = getSocketId()
|
const sid = getSocketId()
|
||||||
if (sid) {
|
if (sid) {
|
||||||
config.headers['X-Socket-Id'] = sid
|
config.headers['X-Socket-Id'] = sid
|
||||||
}
|
}
|
||||||
|
// Attach a per-request idempotency key to all write operations so the
|
||||||
|
// server can deduplicate retried requests (e.g. network blips).
|
||||||
|
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||||
|
const method = (config.method ?? '').toLowerCase()
|
||||||
|
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||||
|
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: Math.random().toString(36).slice(2)
|
||||||
|
config.headers['X-Idempotency-Key'] = key
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response interceptor - handle 401
|
export function isAuthPublicPath(pathname: string): boolean {
|
||||||
|
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
|
||||||
|
const publicPrefixes = ['/shared/', '/public/']
|
||||||
|
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
localStorage.removeItem('auth_token')
|
const { pathname } = window.location
|
||||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
if (!isAuthPublicPath(pathname)) {
|
||||||
window.location.href = '/login'
|
const currentPath = pathname + window.location.search
|
||||||
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||||
|
!window.location.pathname.startsWith('/settings')
|
||||||
|
) {
|
||||||
|
window.location.href = '/settings?mfa=required'
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
const translated = translateRateLimit()
|
||||||
|
const data = error.response.data as { error?: string } | undefined
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
data.error = translated
|
||||||
|
} else {
|
||||||
|
error.response.data = { error: translated }
|
||||||
|
}
|
||||||
|
error.message = translated
|
||||||
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -44,7 +106,7 @@ export const authApi = {
|
|||||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||||
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data),
|
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||||
@@ -59,8 +121,52 @@ export const authApi = {
|
|||||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||||
|
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||||
|
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||||
|
mcpTokens: {
|
||||||
|
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||||
|
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||||
|
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const oauthApi = {
|
||||||
|
/** Validate OAuth authorize params — called by consent page on load */
|
||||||
|
validate: (params: {
|
||||||
|
response_type: string
|
||||||
|
client_id: string
|
||||||
|
redirect_uri: string
|
||||||
|
scope: string
|
||||||
|
state?: string
|
||||||
|
code_challenge: string
|
||||||
|
code_challenge_method: string
|
||||||
|
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
/** Submit user consent (approve or deny) */
|
||||||
|
authorize: (body: {
|
||||||
|
client_id: string
|
||||||
|
redirect_uri: string
|
||||||
|
scope: string
|
||||||
|
state?: string
|
||||||
|
code_challenge: string
|
||||||
|
code_challenge_method: string
|
||||||
|
approved: boolean
|
||||||
|
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||||
|
|
||||||
|
clients: {
|
||||||
|
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||||
|
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||||
|
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||||
|
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||||
|
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||||
|
},
|
||||||
|
|
||||||
|
sessions: {
|
||||||
|
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
|
||||||
|
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tripsApi = {
|
export const tripsApi = {
|
||||||
@@ -75,6 +181,8 @@ export const tripsApi = {
|
|||||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||||
|
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||||
|
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const daysApi = {
|
export const daysApi = {
|
||||||
@@ -91,6 +199,27 @@ export const placesApi = {
|
|||||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||||
|
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
|
||||||
|
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
|
||||||
|
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
|
||||||
|
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
|
},
|
||||||
|
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
|
||||||
|
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||||
|
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
|
},
|
||||||
|
importGoogleList: (tripId: number | string, url: string) =>
|
||||||
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||||
|
importNaverList: (tripId: number | string, url: string) =>
|
||||||
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||||
|
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||||
|
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
@@ -108,18 +237,31 @@ export const assignmentsApi = {
|
|||||||
export const packingApi = {
|
export const packingApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||||
|
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||||
|
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||||
|
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
|
||||||
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||||
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||||
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||||
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const todoApi = {
|
||||||
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
||||||
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||||
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||||
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
||||||
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
|
||||||
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
||||||
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export const tagsApi = {
|
export const tagsApi = {
|
||||||
list: () => apiClient.get('/tags').then(r => r.data),
|
list: () => apiClient.get('/tags').then(r => r.data),
|
||||||
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
|
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
|
||||||
@@ -146,9 +288,16 @@ export const adminApi = {
|
|||||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
|
||||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||||
|
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||||
|
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||||
|
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||||
|
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||||
|
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||||
|
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||||
|
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||||
|
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||||
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||||
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
||||||
@@ -163,17 +312,91 @@ export const adminApi = {
|
|||||||
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
||||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||||
|
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||||
|
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||||
|
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||||
|
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||||
|
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||||
|
revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data),
|
||||||
|
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
|
||||||
|
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||||
|
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||||
|
sendTestNotification: (data: Record<string, unknown>) =>
|
||||||
|
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||||
|
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||||
|
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||||
|
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||||
|
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addonsApi = {
|
export const addonsApi = {
|
||||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const journeyApi = {
|
||||||
|
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||||
|
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
|
||||||
|
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
||||||
|
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
|
||||||
|
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data),
|
||||||
|
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
|
||||||
|
|
||||||
|
// Trips (sync sources)
|
||||||
|
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
|
||||||
|
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
|
||||||
|
|
||||||
|
// Entries
|
||||||
|
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
|
||||||
|
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||||
|
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||||
|
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||||
|
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||||
|
|
||||||
|
// Photos
|
||||||
|
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||||
|
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
|
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
|
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
|
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||||
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||||
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||||
|
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||||
|
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||||
|
|
||||||
|
// Cover
|
||||||
|
uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||||
|
|
||||||
|
// Contributors
|
||||||
|
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
|
||||||
|
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
|
||||||
|
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
|
||||||
|
|
||||||
|
// Share
|
||||||
|
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||||
|
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||||
|
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
|
||||||
|
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||||
|
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||||
|
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||||
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const airportsApi = {
|
||||||
|
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||||
|
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const budgetApi = {
|
export const budgetApi = {
|
||||||
@@ -184,6 +407,9 @@ export const budgetApi = {
|
|||||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
|
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||||
|
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||||
|
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
@@ -197,6 +423,9 @@ export const filesApi = {
|
|||||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||||
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||||
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||||
|
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||||
|
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||||
|
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reservationsApi = {
|
export const reservationsApi = {
|
||||||
@@ -204,6 +433,7 @@ export const reservationsApi = {
|
|||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||||
|
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
@@ -211,6 +441,11 @@ export const weatherApi = {
|
|||||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const configApi = {
|
||||||
|
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||||
|
apiClient.get('/config').then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: () => apiClient.get('/settings').then(r => r.data),
|
get: () => apiClient.get('/settings').then(r => r.data),
|
||||||
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
||||||
@@ -254,9 +489,8 @@ export const backupApi = {
|
|||||||
list: () => apiClient.get('/backup/list').then(r => r.data),
|
list: () => apiClient.get('/backup/list').then(r => r.data),
|
||||||
create: () => apiClient.post('/backup/create').then(r => r.data),
|
create: () => apiClient.post('/backup/create').then(r => r.data),
|
||||||
download: async (filename: string): Promise<void> => {
|
download: async (filename: string): Promise<void> => {
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
const res = await fetch(`/api/backup/download/${filename}`, {
|
const res = await fetch(`/api/backup/download/${filename}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Download failed')
|
if (!res.ok) throw new Error('Download failed')
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -278,4 +512,38 @@ export const backupApi = {
|
|||||||
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const shareApi = {
|
||||||
|
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||||
|
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
|
||||||
|
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||||
|
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||||
|
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||||
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||||
|
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||||
|
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inAppNotificationsApi = {
|
||||||
|
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||||
|
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||||
|
unreadCount: () =>
|
||||||
|
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||||
|
markRead: (id: number) =>
|
||||||
|
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||||
|
markUnread: (id: number) =>
|
||||||
|
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||||
|
markAllRead: () =>
|
||||||
|
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||||
|
delete: (id: number) =>
|
||||||
|
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||||
|
deleteAll: () =>
|
||||||
|
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||||
|
respond: (id: number, response: 'positive' | 'negative') =>
|
||||||
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
|
||||||
|
|
||||||
|
describe('SCOPE_GROUPS', () => {
|
||||||
|
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
|
||||||
|
const expected = [
|
||||||
|
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
|
||||||
|
'places:read', 'places:write',
|
||||||
|
'atlas:read', 'atlas:write',
|
||||||
|
'packing:read', 'packing:write',
|
||||||
|
'todos:read', 'todos:write',
|
||||||
|
'budget:read', 'budget:write',
|
||||||
|
'reservations:read', 'reservations:write',
|
||||||
|
'collab:read', 'collab:write',
|
||||||
|
'notifications:read', 'notifications:write',
|
||||||
|
'vacay:read', 'vacay:write',
|
||||||
|
'geo:read', 'weather:read',
|
||||||
|
]
|
||||||
|
for (const scope of expected) {
|
||||||
|
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-OAUTH-SCOPES-002: each scope entry has labelKey, descriptionKey, groupKey', () => {
|
||||||
|
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
|
||||||
|
expect(keys.labelKey, `${scope} missing labelKey`).toBeTruthy()
|
||||||
|
expect(keys.descriptionKey, `${scope} missing descriptionKey`).toBeTruthy()
|
||||||
|
expect(keys.groupKey, `${scope} missing groupKey`).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ALL_SCOPES', () => {
|
||||||
|
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
|
||||||
|
expect(ALL_SCOPES).toHaveLength(27)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||||
|
expect(ALL_SCOPES).toEqual(Object.keys(SCOPE_GROUPS))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SCOPE_GROUP_NAMES', () => {
|
||||||
|
it('FE-OAUTH-SCOPES-005: contains no duplicate group names', () => {
|
||||||
|
expect(SCOPE_GROUP_NAMES).toHaveLength(new Set(SCOPE_GROUP_NAMES).size)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-OAUTH-SCOPES-006: contains expected groups', () => {
|
||||||
|
const expected = [
|
||||||
|
'oauth.scope.group.trips',
|
||||||
|
'oauth.scope.group.places',
|
||||||
|
'oauth.scope.group.packing',
|
||||||
|
'oauth.scope.group.budget',
|
||||||
|
]
|
||||||
|
for (const g of expected) {
|
||||||
|
expect(SCOPE_GROUP_NAMES).toContain(g)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getScopesByGroup', () => {
|
||||||
|
const identity = (key: string) => key
|
||||||
|
|
||||||
|
it('FE-OAUTH-SCOPES-007: groups all scopes under the correct group key', () => {
|
||||||
|
const groups = getScopesByGroup(identity)
|
||||||
|
// Every scope must appear exactly once across all groups
|
||||||
|
const allScopesInGroups = Object.values(groups).flat().map(s => s.scope)
|
||||||
|
expect(allScopesInGroups).toHaveLength(ALL_SCOPES.length)
|
||||||
|
for (const scope of ALL_SCOPES) {
|
||||||
|
expect(allScopesInGroups).toContain(scope)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-OAUTH-SCOPES-008: each item has scope, label, description, group', () => {
|
||||||
|
const groups = getScopesByGroup(identity)
|
||||||
|
for (const items of Object.values(groups)) {
|
||||||
|
for (const item of items) {
|
||||||
|
expect(item.scope).toBeTruthy()
|
||||||
|
expect(item.label).toBeTruthy()
|
||||||
|
expect(item.description).toBeTruthy()
|
||||||
|
expect(item.group).toBeTruthy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-OAUTH-SCOPES-009: trips group contains trips:read and trips:write', () => {
|
||||||
|
const groups = getScopesByGroup(identity)
|
||||||
|
const tripsGroup = groups['oauth.scope.group.trips']
|
||||||
|
expect(tripsGroup).toBeDefined()
|
||||||
|
const scopeNames = tripsGroup.map(s => s.scope)
|
||||||
|
expect(scopeNames).toContain('trips:read')
|
||||||
|
expect(scopeNames).toContain('trips:write')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-OAUTH-SCOPES-010: uses translated group name as key', () => {
|
||||||
|
const t = (key: string) => key === 'oauth.scope.group.trips' ? 'Trips' : key
|
||||||
|
const groups = getScopesByGroup(t)
|
||||||
|
expect(groups['Trips']).toBeDefined()
|
||||||
|
expect(groups['oauth.scope.group.trips']).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Human-readable scope definitions for the OAuth consent page.
|
||||||
|
// Must stay in sync with server/src/mcp/scopes.ts
|
||||||
|
|
||||||
|
export interface ScopeInfo {
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopeKeys {
|
||||||
|
labelKey: string
|
||||||
|
descriptionKey: string
|
||||||
|
groupKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||||
|
'trips:read': { labelKey: 'oauth.scope.trips:read.label', descriptionKey: 'oauth.scope.trips:read.description', groupKey: 'oauth.scope.group.trips' },
|
||||||
|
'trips:write': { labelKey: 'oauth.scope.trips:write.label', descriptionKey: 'oauth.scope.trips:write.description', groupKey: 'oauth.scope.group.trips' },
|
||||||
|
'trips:delete': { labelKey: 'oauth.scope.trips:delete.label', descriptionKey: 'oauth.scope.trips:delete.description', groupKey: 'oauth.scope.group.trips' },
|
||||||
|
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
|
||||||
|
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
|
||||||
|
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
|
||||||
|
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
|
||||||
|
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
|
||||||
|
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
|
||||||
|
'packing:write': { labelKey: 'oauth.scope.packing:write.label', descriptionKey: 'oauth.scope.packing:write.description', groupKey: 'oauth.scope.group.packing' },
|
||||||
|
'todos:read': { labelKey: 'oauth.scope.todos:read.label', descriptionKey: 'oauth.scope.todos:read.description', groupKey: 'oauth.scope.group.todos' },
|
||||||
|
'todos:write': { labelKey: 'oauth.scope.todos:write.label', descriptionKey: 'oauth.scope.todos:write.description', groupKey: 'oauth.scope.group.todos' },
|
||||||
|
'budget:read': { labelKey: 'oauth.scope.budget:read.label', descriptionKey: 'oauth.scope.budget:read.description', groupKey: 'oauth.scope.group.budget' },
|
||||||
|
'budget:write': { labelKey: 'oauth.scope.budget:write.label', descriptionKey: 'oauth.scope.budget:write.description', groupKey: 'oauth.scope.group.budget' },
|
||||||
|
'reservations:read': { labelKey: 'oauth.scope.reservations:read.label', descriptionKey: 'oauth.scope.reservations:read.description', groupKey: 'oauth.scope.group.reservations' },
|
||||||
|
'reservations:write': { labelKey: 'oauth.scope.reservations:write.label', descriptionKey: 'oauth.scope.reservations:write.description', groupKey: 'oauth.scope.group.reservations' },
|
||||||
|
'collab:read': { labelKey: 'oauth.scope.collab:read.label', descriptionKey: 'oauth.scope.collab:read.description', groupKey: 'oauth.scope.group.collab' },
|
||||||
|
'collab:write': { labelKey: 'oauth.scope.collab:write.label', descriptionKey: 'oauth.scope.collab:write.description', groupKey: 'oauth.scope.group.collab' },
|
||||||
|
'notifications:read': { labelKey: 'oauth.scope.notifications:read.label', descriptionKey: 'oauth.scope.notifications:read.description', groupKey: 'oauth.scope.group.notifications' },
|
||||||
|
'notifications:write': { labelKey: 'oauth.scope.notifications:write.label', descriptionKey: 'oauth.scope.notifications:write.description', groupKey: 'oauth.scope.group.notifications' },
|
||||||
|
'vacay:read': { labelKey: 'oauth.scope.vacay:read.label', descriptionKey: 'oauth.scope.vacay:read.description', groupKey: 'oauth.scope.group.vacay' },
|
||||||
|
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
|
||||||
|
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
|
||||||
|
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
|
||||||
|
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
|
||||||
|
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
|
||||||
|
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
||||||
|
|
||||||
|
// Group all scopes for the client registration form
|
||||||
|
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
|
||||||
|
|
||||||
|
export function getScopesByGroup(t: (key: string) => string): Record<string, Array<{ scope: string } & ScopeInfo>> {
|
||||||
|
const groups: Record<string, Array<{ scope: string } & ScopeInfo>> = {}
|
||||||
|
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
|
||||||
|
const group = t(keys.groupKey)
|
||||||
|
if (!groups[group]) groups[group] = []
|
||||||
|
groups[group].push({ scope, label: t(keys.labelKey), description: t(keys.descriptionKey), group })
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
+68
-17
@@ -9,9 +9,12 @@ let reconnectDelay = 1000
|
|||||||
const MAX_RECONNECT_DELAY = 30000
|
const MAX_RECONNECT_DELAY = 30000
|
||||||
const listeners = new Set<WebSocketListener>()
|
const listeners = new Set<WebSocketListener>()
|
||||||
const activeTrips = new Set<string>()
|
const activeTrips = new Set<string>()
|
||||||
let currentToken: string | null = null
|
let shouldReconnect = false
|
||||||
let refetchCallback: RefetchCallback | null = null
|
let refetchCallback: RefetchCallback | null = null
|
||||||
let mySocketId: string | null = null
|
let mySocketId: string | null = null
|
||||||
|
let connecting = false
|
||||||
|
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
|
||||||
|
let preReconnectHook: (() => Promise<void>) | null = null
|
||||||
|
|
||||||
export function getSocketId(): string | null {
|
export function getSocketId(): string | null {
|
||||||
return mySocketId
|
return mySocketId
|
||||||
@@ -21,9 +24,38 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
|||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWsUrl(token: string): string {
|
/**
|
||||||
|
* Register a hook that runs (and is awaited) before the refetch callback
|
||||||
|
* fires on WS reconnect. Use this to flush the mutation queue so queued
|
||||||
|
* local writes reach the server before the app reads back canonical state.
|
||||||
|
* Pass null to clear.
|
||||||
|
*/
|
||||||
|
export function setPreReconnectHook(fn: (() => Promise<void>) | null): void {
|
||||||
|
preReconnectHook = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWsUrl(wsToken: string): string {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
return `${protocol}://${location.host}/ws?token=${token}`
|
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWsToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/ws-token', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (resp.status === 401) {
|
||||||
|
// Session expired — stop reconnecting
|
||||||
|
shouldReconnect = false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!resp.ok) return null
|
||||||
|
const { token } = await resp.json()
|
||||||
|
return token as string
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(event: MessageEvent): void {
|
function handleMessage(event: MessageEvent): void {
|
||||||
@@ -45,19 +77,29 @@ function scheduleReconnect(): void {
|
|||||||
if (reconnectTimer) return
|
if (reconnectTimer) return
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
if (currentToken) {
|
if (shouldReconnect) {
|
||||||
connectInternal(currentToken, true)
|
connectInternal(true)
|
||||||
}
|
}
|
||||||
}, reconnectDelay)
|
}, reconnectDelay)
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectInternal(token: string, _isReconnect = false): void {
|
async function connectInternal(_isReconnect = false): Promise<void> {
|
||||||
|
if (connecting) return
|
||||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getWsUrl(token)
|
connecting = true
|
||||||
|
const wsToken = await fetchWsToken()
|
||||||
|
connecting = false
|
||||||
|
|
||||||
|
if (!wsToken) {
|
||||||
|
if (shouldReconnect) scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getWsUrl(wsToken)
|
||||||
socket = new WebSocket(url)
|
socket = new WebSocket(url)
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
@@ -69,11 +111,20 @@ function connectInternal(token: string, _isReconnect = false): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (refetchCallback) {
|
if (refetchCallback) {
|
||||||
activeTrips.forEach(tripId => {
|
const doRefetch = () => {
|
||||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
activeTrips.forEach(tripId => {
|
||||||
console.error('Failed to refetch trip data on reconnect:', err)
|
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||||
}
|
console.error('Failed to refetch trip data on reconnect:', err)
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Flush queued mutations first so local writes land before server read-back.
|
||||||
|
// If the hook fails, still refetch to keep the UI correct.
|
||||||
|
if (preReconnectHook) {
|
||||||
|
preReconnectHook().catch(console.error).then(doRefetch)
|
||||||
|
} else {
|
||||||
|
doRefetch()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +133,7 @@ function connectInternal(token: string, _isReconnect = false): void {
|
|||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
socket = null
|
socket = null
|
||||||
if (currentToken) {
|
if (shouldReconnect) {
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,18 +143,18 @@ function connectInternal(token: string, _isReconnect = false): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connect(token: string): void {
|
export function connect(): void {
|
||||||
currentToken = token
|
shouldReconnect = true
|
||||||
reconnectDelay = 1000
|
reconnectDelay = 1000
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
}
|
}
|
||||||
connectInternal(token, false)
|
connectInternal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disconnect(): void {
|
export function disconnect(): void {
|
||||||
currentToken = null
|
shouldReconnect = false
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
|
||||||
|
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
|
import { ToastContainer } from '../shared/Toast';
|
||||||
|
import AddonManager from './AddonManager';
|
||||||
|
|
||||||
|
function buildAddon(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'todo',
|
||||||
|
name: 'Todo List',
|
||||||
|
description: 'Track tasks',
|
||||||
|
icon: 'ListChecks',
|
||||||
|
type: 'trip',
|
||||||
|
enabled: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn(() => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
seedStore(useSettingsStore, { settings: { dark_mode: false } });
|
||||||
|
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AddonManager', () => {
|
||||||
|
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
return HttpResponse.json({ addons: [] });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<AddonManager />);
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
|
||||||
|
render(<AddonManager />);
|
||||||
|
await screen.findByText('No addons available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({ addons: [buildAddon({ id: 'todo', name: 'Todo List', type: 'trip' })] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AddonManager />);
|
||||||
|
await screen.findByText('Todo List');
|
||||||
|
// Section header contains "Trip" and "Available as a tab within each trip"
|
||||||
|
expect(screen.getAllByText(/Trip/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(/Available as a tab within each trip/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-004: global and integration sections render when present', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
addons: [
|
||||||
|
buildAddon({ id: 'global1', name: 'Global Feature', type: 'global' }),
|
||||||
|
buildAddon({ id: 'int1', name: 'Integration Feature', type: 'integration' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AddonManager />);
|
||||||
|
await screen.findByText('Global Feature');
|
||||||
|
expect(screen.getAllByText(/Global/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/Integration/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
||||||
|
),
|
||||||
|
http.put('/api/admin/addons/todo', () =>
|
||||||
|
HttpResponse.json({ success: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><AddonManager /></>);
|
||||||
|
await screen.findByText('Todo List');
|
||||||
|
|
||||||
|
// Get toggle button - use getAllByRole since there might be multiple buttons
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||||
|
expect(toggleBtn).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Before click - disabled state (border-primary bg)
|
||||||
|
await user.click(toggleBtn!);
|
||||||
|
|
||||||
|
// After click - success toast
|
||||||
|
await screen.findByText('Addon updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
|
||||||
|
),
|
||||||
|
http.put('/api/admin/addons/todo', () =>
|
||||||
|
HttpResponse.error()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><AddonManager /></>);
|
||||||
|
await screen.findByText('Todo List');
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||||
|
await user.click(toggleBtn!);
|
||||||
|
|
||||||
|
// Error toast appears
|
||||||
|
await screen.findByText('Failed to update addon');
|
||||||
|
|
||||||
|
// The disabled text should be back after rollback
|
||||||
|
await waitFor(() => {
|
||||||
|
const disabledTexts = screen.getAllByText('Disabled');
|
||||||
|
expect(disabledTexts.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-007: bag tracking sub-toggle renders when packing addon is enabled', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mockToggle = vi.fn();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
|
||||||
|
);
|
||||||
|
await screen.findByText('Bag Tracking');
|
||||||
|
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
|
||||||
|
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
|
||||||
|
);
|
||||||
|
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
|
||||||
|
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||||
|
// There should be two toggle buttons: one for the addon, one for bag tracking
|
||||||
|
await user.click(allBtns[allBtns.length - 1]);
|
||||||
|
expect(mockToggle).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-008: bag tracking hidden when packing addon is disabled', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />
|
||||||
|
);
|
||||||
|
await screen.findByText('Lists');
|
||||||
|
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AddonManager bagTrackingEnabled={false} />);
|
||||||
|
await screen.findByText('Lists');
|
||||||
|
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
addons: [
|
||||||
|
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||||
|
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
|
||||||
|
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
|
||||||
|
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AddonManager />);
|
||||||
|
|
||||||
|
// Provider sub-rows are visible under Journey addon
|
||||||
|
await screen.findByText('Unsplash');
|
||||||
|
expect(screen.getByText('Pexels')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Journey addon is rendered
|
||||||
|
expect(screen.getByText('Journey')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Toggle buttons: journey toggle + 2 provider toggles
|
||||||
|
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||||
|
expect(toggleBtns.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/addons', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
addons: [buildAddon({ id: 'mystery', name: 'Mystery Addon', icon: 'NonExistentIcon', type: 'trip' })],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Should not throw; Puzzle icon is used as fallback
|
||||||
|
expect(() => render(<AddonManager />)).not.toThrow();
|
||||||
|
await screen.findByText('Mystery Addon');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,33 @@ import { useEffect, useState } from 'react'
|
|||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||||
|
<path d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SynologyIcon({ size = 14 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||||
|
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
|
||||||
|
immich: ImmichIcon,
|
||||||
|
synologyphotos: SynologyIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Addon {
|
interface Addon {
|
||||||
@@ -14,7 +36,17 @@ interface Addon {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
icon: string
|
icon: string
|
||||||
|
type: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderOption {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
enabled: boolean
|
||||||
|
toggle: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddonIconProps {
|
interface AddonIconProps {
|
||||||
@@ -27,12 +59,22 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
|||||||
return <Icon size={size} />
|
return <Icon size={size} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
|
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||||
|
|
||||||
|
const COLLAB_SUB_FEATURES = [
|
||||||
|
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||||
|
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||||
|
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||||
|
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [addons, setAddons] = useState([])
|
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||||
|
const [addons, setAddons] = useState<Addon[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,13 +93,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggle = async (addon) => {
|
const handleToggle = async (addon: Addon) => {
|
||||||
const newEnabled = !addon.enabled
|
const newEnabled = !addon.enabled
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||||
try {
|
try {
|
||||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||||
window.dispatchEvent(new Event('addons-changed'))
|
refreshGlobalAddons()
|
||||||
toast.success(t('admin.addons.toast.updated'))
|
toast.success(t('admin.addons.toast.updated'))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Rollback
|
// Rollback
|
||||||
@@ -66,8 +108,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
const isPhotoProviderAddon = (addon: Addon) => {
|
||||||
|
return addon.type === 'photo_provider'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPhotosAddon = (addon: Addon) => {
|
||||||
|
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
|
||||||
|
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||||
|
const enableProvider = !providerAddon.enabled
|
||||||
|
const prev = addons
|
||||||
|
|
||||||
|
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||||
|
refreshGlobalAddons()
|
||||||
|
toast.success(t('admin.addons.toast.updated'))
|
||||||
|
} catch {
|
||||||
|
setAddons(prev)
|
||||||
|
toast.error(t('admin.addons.toast.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||||
|
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
||||||
|
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
|
||||||
const globalAddons = addons.filter(a => a.type === 'global')
|
const globalAddons = addons.filter(a => a.type === 'global')
|
||||||
|
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||||
|
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||||
|
key: provider.id,
|
||||||
|
label: provider.name,
|
||||||
|
description: provider.description,
|
||||||
|
enabled: provider.enabled,
|
||||||
|
toggle: () => handleTogglePhotoProvider(provider),
|
||||||
|
}))
|
||||||
|
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -108,6 +186,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||||
|
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
||||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||||
@@ -125,6 +204,36 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||||
|
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{COLLAB_SUB_FEATURES.map(feat => {
|
||||||
|
const enabled = collabFeatures[feat.key]
|
||||||
|
const Icon = feat.icon
|
||||||
|
return (
|
||||||
|
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||||
|
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
|
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +249,55 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{globalAddons.map(addon => (
|
{globalAddons.map(addon => (
|
||||||
|
<div key={addon.id}>
|
||||||
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
{/* Memories providers as sub-items under Journey addon */}
|
||||||
|
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||||
|
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{providerOptions.map(provider => {
|
||||||
|
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||||
|
return (
|
||||||
|
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||||
|
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
|
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={provider.toggle}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Integration Addons */}
|
||||||
|
{integrationAddons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{integrationAddons.map(addon => (
|
||||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,8 +311,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
|||||||
|
|
||||||
interface AddonRowProps {
|
interface AddonRowProps {
|
||||||
addon: Addon
|
addon: Addon
|
||||||
onToggle: (addonId: string) => void
|
onToggle: (addon: Addon) => void
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
|
statusOverride?: boolean
|
||||||
|
hideToggle?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||||
@@ -169,9 +329,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||||
const isComingSoon = false
|
const isComingSoon = false
|
||||||
const label = getAddonLabel(t, addon)
|
const label = getAddonLabel(t, addon)
|
||||||
|
const displayName = nameOverride || label.name
|
||||||
|
const displayDescription = descriptionOverride || label.description
|
||||||
|
const enabledState = statusOverride ?? addon.enabled
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
@@ -182,41 +345,40 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
|
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
|
||||||
{isComingSoon && (
|
{isComingSoon && (
|
||||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||||
Coming Soon
|
Coming Soon
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
||||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{!hideToggle && (
|
||||||
onClick={() => !isComingSoon && onToggle(addon)}
|
<button
|
||||||
disabled={isComingSoon}
|
onClick={() => !isComingSoon && onToggle(addon)}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
disabled={isComingSoon}
|
||||||
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
>
|
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||||
<span
|
>
|
||||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
<span
|
||||||
style={{
|
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||||
background: 'var(--bg-card)',
|
style={{
|
||||||
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
background: 'var(--bg-card)',
|
||||||
}}
|
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||||
/>
|
}}
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import { ToastContainer } from '../shared/Toast';
|
||||||
|
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
|
||||||
|
|
||||||
|
const TOKEN_1 = {
|
||||||
|
id: 1,
|
||||||
|
name: 'CI Token',
|
||||||
|
token_prefix: 'trek_abc',
|
||||||
|
created_at: '2025-01-15T00:00:00Z',
|
||||||
|
last_used_at: null,
|
||||||
|
user_id: 10,
|
||||||
|
username: 'alice',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN_2 = {
|
||||||
|
id: 2,
|
||||||
|
name: 'Ops Token',
|
||||||
|
token_prefix: 'trek_xyz',
|
||||||
|
created_at: '2025-03-01T00:00:00Z',
|
||||||
|
last_used_at: '2025-04-01T00:00:00Z',
|
||||||
|
user_id: 11,
|
||||||
|
username: 'bob',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AdminMcpTokensPanel', () => {
|
||||||
|
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
return HttpResponse.json({ tokens: [] });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('No MCP tokens have been created yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', () =>
|
||||||
|
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('CI Token');
|
||||||
|
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||||
|
// token_prefix is rendered as `{token.token_prefix}...` — two adjacent text nodes
|
||||||
|
expect(screen.getByText(/trek_abc/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/trek_xyz/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', () =>
|
||||||
|
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('CI Token');
|
||||||
|
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', () =>
|
||||||
|
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('CI Token');
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByTitle('Delete');
|
||||||
|
await user.click(deleteButtons[0]);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||||
|
// Dialog Delete button has visible text "Delete"; trash icon buttons have no text content
|
||||||
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', () =>
|
||||||
|
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('CI Token');
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByTitle('Delete');
|
||||||
|
await user.click(deleteButtons[0]);
|
||||||
|
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Cancel'));
|
||||||
|
|
||||||
|
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('CI Token')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', () =>
|
||||||
|
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('CI Token');
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByTitle('Delete');
|
||||||
|
await user.click(deleteButtons[0]);
|
||||||
|
expect(screen.getByText('Delete Token')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const backdrop = document.querySelector('.fixed.inset-0');
|
||||||
|
expect(backdrop).toBeInTheDocument();
|
||||||
|
await user.click(backdrop!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', () =>
|
||||||
|
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||||
|
),
|
||||||
|
http.delete('/api/admin/mcp-tokens/:id', () =>
|
||||||
|
HttpResponse.json({ success: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||||
|
await screen.findByText('CI Token');
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByTitle('Delete');
|
||||||
|
await user.click(deleteButtons[0]);
|
||||||
|
await user.click(screen.getByText('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText('CI Token')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Ops Token')).toBeInTheDocument();
|
||||||
|
await screen.findByText('Token deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', () =>
|
||||||
|
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
|
||||||
|
),
|
||||||
|
http.delete('/api/admin/mcp-tokens/:id', () =>
|
||||||
|
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||||
|
await screen.findByText('CI Token');
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByTitle('Delete');
|
||||||
|
await user.click(deleteButtons[0]);
|
||||||
|
await user.click(screen.getByText('Delete'));
|
||||||
|
|
||||||
|
await screen.findByText('Failed to delete token');
|
||||||
|
expect(screen.getByText('CI Token')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/mcp-tokens', () =>
|
||||||
|
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||||
|
await screen.findByText('Failed to load tokens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/oauth-sessions', async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
return HttpResponse.json({ sessions: [] });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/oauth-sessions', () =>
|
||||||
|
HttpResponse.json({ sessions: [] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('No active OAuth sessions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/oauth-sessions', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
client_name: 'Claude Desktop',
|
||||||
|
username: 'alice',
|
||||||
|
scopes: ['trips:read', 'budget:read'],
|
||||||
|
created_at: '2025-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('Claude Desktop');
|
||||||
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('trips:read')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
|
||||||
|
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/oauth-sessions', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
sessions: [
|
||||||
|
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<AdminMcpTokensPanel />);
|
||||||
|
await screen.findByText('App');
|
||||||
|
// "+1 more" button should appear
|
||||||
|
const moreBtn = await screen.findByText(/\+1 more/);
|
||||||
|
expect(moreBtn).toBeInTheDocument();
|
||||||
|
await user.click(moreBtn);
|
||||||
|
// After expand, "show less" appears
|
||||||
|
expect(await screen.findByText('show less')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/oauth-sessions', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
sessions: [
|
||||||
|
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.delete('/api/admin/oauth-sessions/5', () =>
|
||||||
|
HttpResponse.json({ success: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||||
|
await screen.findByText('Revoke Me');
|
||||||
|
|
||||||
|
// Click the revoke (trash) button next to the session
|
||||||
|
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
|
// Confirmation modal opens
|
||||||
|
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
||||||
|
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
|
||||||
|
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||||
|
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||||
|
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/oauth-sessions', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
sessions: [
|
||||||
|
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.delete('/api/admin/oauth-sessions/6', () =>
|
||||||
|
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||||
|
await screen.findByText('Error Session');
|
||||||
|
|
||||||
|
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||||
|
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||||
|
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||||
|
await screen.findByText('Failed to revoke session');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
interface AdminOAuthSession {
|
||||||
|
id: number
|
||||||
|
client_id: string
|
||||||
|
client_name: string
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
scopes: string[]
|
||||||
|
access_token_expires_at: string
|
||||||
|
refresh_token_expires_at: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminMcpToken {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
token_prefix: string
|
||||||
|
created_at: string
|
||||||
|
last_used_at: string | null
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPES_PREVIEW = 6
|
||||||
|
|
||||||
|
export default function AdminMcpTokensPanel() {
|
||||||
|
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
|
||||||
|
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||||
|
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||||
|
const [tokensLoading, setTokensLoading] = useState(true)
|
||||||
|
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
|
||||||
|
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const toggleScopes = (id: number) =>
|
||||||
|
setExpandedScopes(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
const toast = useToast()
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.oauthSessions()
|
||||||
|
.then(d => setSessions(d.sessions || []))
|
||||||
|
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
|
||||||
|
.finally(() => setSessionsLoading(false))
|
||||||
|
|
||||||
|
adminApi.mcpTokens()
|
||||||
|
.then(d => setTokens(d.tokens || []))
|
||||||
|
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||||
|
.finally(() => setTokensLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRevoke = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminApi.revokeOAuthSession(id)
|
||||||
|
setSessions(prev => prev.filter(s => s.id !== id))
|
||||||
|
setRevokeConfirmId(null)
|
||||||
|
toast.success(t('admin.oauthSessions.revokeSuccess'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.oauthSessions.revokeError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteMcpToken(id)
|
||||||
|
setTokens(prev => prev.filter(tk => tk.id !== id))
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
toast.success(t('admin.mcpTokens.deleteSuccess'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.mcpTokens.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth Sessions */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||||
|
{sessionsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
|
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
|
||||||
|
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||||
|
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||||
|
<span>{t('admin.oauthSessions.owner')}</span>
|
||||||
|
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
{sessions.map((session, i) => {
|
||||||
|
const expanded = expandedScopes.has(session.id)
|
||||||
|
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||||
|
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||||
|
return (
|
||||||
|
<div key={session.id}
|
||||||
|
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
|
||||||
|
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{visible.map(scope => (
|
||||||
|
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
{scope}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{!expanded && hidden > 0 && (
|
||||||
|
<button onClick={() => toggleScopes(session.id)}
|
||||||
|
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
+{hidden} more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{expanded && hidden > 0 && (
|
||||||
|
<button onClick={() => toggleScopes(session.id)}
|
||||||
|
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
show less
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="whitespace-nowrap">{session.username}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setRevokeConfirmId(session.id)}
|
||||||
|
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MCP Tokens */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||||
|
{tokensLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
|
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||||
|
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||||
|
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||||
|
<span>{t('admin.mcpTokens.owner')}</span>
|
||||||
|
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||||
|
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
{tokens.map((token, i) => (
|
||||||
|
<div key={token.id}
|
||||||
|
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||||
|
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||||
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="whitespace-nowrap">{token.username}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||||
|
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revoke OAuth session modal */}
|
||||||
|
{revokeConfirmId !== null && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||||
|
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setRevokeConfirmId(null)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete MCP token modal */}
|
||||||
|
{deleteConfirmId !== null && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||||
|
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setDeleteConfirmId(null)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import AuditLogPanel from './AuditLogPanel';
|
||||||
|
|
||||||
|
const ENTRY_1 = {
|
||||||
|
id: 1,
|
||||||
|
created_at: '2025-06-01T10:30:00Z',
|
||||||
|
user_id: 5,
|
||||||
|
username: 'alice',
|
||||||
|
user_email: 'alice@example.com',
|
||||||
|
action: 'trip.create',
|
||||||
|
resource: '/trips/42',
|
||||||
|
details: { title: 'Test' },
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTRY_2 = {
|
||||||
|
id: 2,
|
||||||
|
created_at: '2025-06-02T11:00:00Z',
|
||||||
|
user_id: 6,
|
||||||
|
username: 'bob',
|
||||||
|
user_email: 'bob@example.com',
|
||||||
|
action: 'trip.delete',
|
||||||
|
resource: '/trips/43',
|
||||||
|
details: null,
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuditLogPanel', () => {
|
||||||
|
it('FE-ADMIN-AUDIT-001: loading state shown on mount', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', async () => {
|
||||||
|
await new Promise(() => {}); // never resolves
|
||||||
|
return HttpResponse.json({ entries: [], total: 0 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('table')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', () =>
|
||||||
|
HttpResponse.json({ entries: [], total: 0 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
await screen.findByText('No audit entries yet.');
|
||||||
|
expect(document.querySelector('table')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', () =>
|
||||||
|
HttpResponse.json({ entries: [ENTRY_1], total: 1 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
await screen.findByText('trip.create');
|
||||||
|
expect(screen.getByText('Time')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('User')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Action')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Resource')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('IP')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Details')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('/trips/42')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('127.0.0.1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('{"title":"Test"}')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-004: userLabel fallback chain', async () => {
|
||||||
|
const entries = [
|
||||||
|
{ ...ENTRY_1, id: 10, username: 'alice', user_email: null, user_id: 5, action: 'a.username' },
|
||||||
|
{ ...ENTRY_1, id: 11, username: null, user_email: 'bob@example.com', user_id: 6, action: 'a.email' },
|
||||||
|
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
|
||||||
|
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
|
||||||
|
];
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', () =>
|
||||||
|
HttpResponse.json({ entries, total: 4 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
await screen.findByText('a.username');
|
||||||
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('#7')).toBeInTheDocument();
|
||||||
|
// '—' appears multiple times (null resource, null ip for some, null user) — just check it exists
|
||||||
|
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-005: dash shown for null resource, ip, and details', async () => {
|
||||||
|
const entry = {
|
||||||
|
...ENTRY_1,
|
||||||
|
id: 20,
|
||||||
|
action: 'a.nulls',
|
||||||
|
resource: null,
|
||||||
|
ip: null,
|
||||||
|
details: null,
|
||||||
|
};
|
||||||
|
const entryEmptyDetails = {
|
||||||
|
...ENTRY_1,
|
||||||
|
id: 21,
|
||||||
|
action: 'a.emptyobj',
|
||||||
|
resource: '/ok',
|
||||||
|
ip: '1.2.3.4',
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', () =>
|
||||||
|
HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
await screen.findByText('a.nulls');
|
||||||
|
// null resource, null ip, null details → three '—' for entry; empty obj details → another '—'
|
||||||
|
const dashes = screen.getAllByText('—');
|
||||||
|
expect(dashes.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', () =>
|
||||||
|
HttpResponse.json({ entries: [ENTRY_1], total: 50 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
await screen.findByText('trip.create');
|
||||||
|
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-007: "Load more" appends entries', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
|
||||||
|
}
|
||||||
|
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
await screen.findByText('trip.create');
|
||||||
|
const loadMoreBtn = screen.getByText('Load more');
|
||||||
|
expect(loadMoreBtn).toBeInTheDocument();
|
||||||
|
await user.click(loadMoreBtn);
|
||||||
|
await screen.findByText('trip.delete');
|
||||||
|
expect(screen.getByText('trip.create')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', () =>
|
||||||
|
HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
await screen.findByText('trip.create');
|
||||||
|
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-009: Refresh resets list to page 1', async () => {
|
||||||
|
const PAGE1_ENTRY = { ...ENTRY_1, id: 100, action: 'phase1.action' };
|
||||||
|
const PAGE2_ENTRY = { ...ENTRY_2, id: 101, action: 'phase2.action' };
|
||||||
|
const REFRESH_ENTRY = { ...ENTRY_2, id: 102, action: 'phase3.refresh' };
|
||||||
|
let callCount = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return HttpResponse.json({ entries: [PAGE1_ENTRY], total: 2 });
|
||||||
|
}
|
||||||
|
if (callCount === 2) {
|
||||||
|
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
|
||||||
|
}
|
||||||
|
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
// Initial load: PAGE1_ENTRY visible, load more
|
||||||
|
await screen.findByText('phase1.action');
|
||||||
|
const loadMoreBtn = screen.getByText('Load more');
|
||||||
|
await user.click(loadMoreBtn);
|
||||||
|
await screen.findByText('phase2.action');
|
||||||
|
// Now refresh
|
||||||
|
const refreshBtn = screen.getByText('Refresh');
|
||||||
|
await user.click(refreshBtn);
|
||||||
|
// After refresh, only REFRESH_ENTRY should be visible
|
||||||
|
await screen.findByText('phase3.refresh');
|
||||||
|
await waitFor(() => expect(screen.queryByText('phase1.action')).not.toBeInTheDocument());
|
||||||
|
expect(screen.queryByText('phase2.action')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-AUDIT-010: Refresh button is disabled while loading', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/audit-log', async () => {
|
||||||
|
await new Promise(() => {}); // never resolves
|
||||||
|
return HttpResponse.json({ entries: [], total: 0 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<AuditLogPanel serverTimezone="UTC" />);
|
||||||
|
const refreshBtn = screen.getByText('Refresh');
|
||||||
|
expect(refreshBtn.closest('button')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { RefreshCw, ClipboardList } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AuditEntry {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
user_id: number | null
|
||||||
|
username: string | null
|
||||||
|
user_email: string | null
|
||||||
|
action: string
|
||||||
|
resource: string | null
|
||||||
|
details: Record<string, unknown> | null
|
||||||
|
ip: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLogPanelProps {
|
||||||
|
serverTimezone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const limit = 100
|
||||||
|
|
||||||
|
const loadFirstPage = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
|
||||||
|
entries: AuditEntry[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
setEntries(data.entries || [])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
setOffset(0)
|
||||||
|
} catch {
|
||||||
|
setEntries([])
|
||||||
|
setTotal(0)
|
||||||
|
setOffset(0)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
const nextOffset = offset + limit
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
|
||||||
|
entries: AuditEntry[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
setEntries((prev) => [...prev, ...(data.entries || [])])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
setOffset(nextOffset)
|
||||||
|
} catch {
|
||||||
|
/* keep existing */
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [offset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFirstPage()
|
||||||
|
}, [loadFirstPage])
|
||||||
|
|
||||||
|
const fmtTime = (iso: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
timeZone: serverTimezone || undefined,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDetails = (d: Record<string, unknown> | null) => {
|
||||||
|
if (!d || Object.keys(d).length === 0) return '—'
|
||||||
|
try {
|
||||||
|
return JSON.stringify(d)
|
||||||
|
} catch {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLabel = (e: AuditEntry) => {
|
||||||
|
if (e.username) return e.username
|
||||||
|
if (e.user_email) return e.user_email
|
||||||
|
if (e.user_id != null) return `#${e.user_id}`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
<ClipboardList size={20} />
|
||||||
|
{t('admin.tabs.audit')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => loadFirstPage()}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
{t('admin.audit.refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('admin.audit.showing', { count: entries.length, total })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||||
|
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
||||||
|
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
||||||
|
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
||||||
|
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
||||||
|
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
||||||
|
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
||||||
|
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.length < total && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => loadMore()}
|
||||||
|
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('admin.audit.loadMore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { server } from '../../../tests/helpers/msw/server'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import BackupPanel from './BackupPanel'
|
||||||
|
import { ToastContainer } from '../shared/Toast'
|
||||||
|
|
||||||
|
const manualBackup = {
|
||||||
|
filename: 'backup-2025-01-15.zip',
|
||||||
|
created_at: '2025-01-15T10:00:00Z',
|
||||||
|
size: 2048000,
|
||||||
|
}
|
||||||
|
const autoBackup = {
|
||||||
|
filename: 'auto-backup-2025-02-01.zip',
|
||||||
|
created_at: '2025-02-01T02:00:00Z',
|
||||||
|
size: 1024000,
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBackupHandlers() {
|
||||||
|
return [
|
||||||
|
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
|
||||||
|
http.get('/api/backup/auto-settings', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||||
|
timezone: 'UTC',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToggleButton() {
|
||||||
|
// The enable toggle is a <button> inside a <label> that contains "Enable auto-backup"
|
||||||
|
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement
|
||||||
|
return label.querySelector('button') as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BackupPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
server.use(...defaultBackupHandlers())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.useRealTimers()
|
||||||
|
server.resetHandlers()
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-001: Loading state
|
||||||
|
it('FE-ADMIN-BKP-001: shows loading spinner while fetching backups', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/backup/list', async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
return HttpResponse.json({ backups: [] })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
render(<BackupPanel />)
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-002: Empty state
|
||||||
|
it('FE-ADMIN-BKP-002: shows empty state when no backups exist', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })),
|
||||||
|
)
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('No backups yet')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Create first backup')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-003: Backup list renders filename, size, and date
|
||||||
|
it('FE-ADMIN-BKP-003: renders filename, formatted size, and date for a backup', async () => {
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText('2.0 MB')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-004: Auto-backup badge shown for auto-backup filenames
|
||||||
|
it('FE-ADMIN-BKP-004: shows Auto badge for auto-backup filenames', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })),
|
||||||
|
)
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Auto')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-005: Create backup success
|
||||||
|
it('FE-ADMIN-BKP-005: creates backup and shows success toast', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
server.use(
|
||||||
|
http.post('/api/backup/create', () => HttpResponse.json({ success: true })),
|
||||||
|
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
|
||||||
|
)
|
||||||
|
render(<><ToastContainer /><BackupPanel /></>)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
await user.click(screen.getByTitle('Create Backup'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Backup created successfully')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-006: Restore opens confirmation modal
|
||||||
|
it('FE-ADMIN-BKP-006: clicking Restore opens confirmation modal', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
await user.click(screen.getAllByText('Restore')[0])
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(screen.getByText('Yes, restore')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Cancel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-007: Cancel dismisses modal without calling restore API
|
||||||
|
it('FE-ADMIN-BKP-007: cancel dismisses the restore modal without calling the API', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
let restoreCalled = false
|
||||||
|
server.use(
|
||||||
|
http.post('/api/backup/restore/:filename', () => {
|
||||||
|
restoreCalled = true
|
||||||
|
return HttpResponse.json({ success: true })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
await user.click(screen.getAllByText('Restore')[0])
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
await user.click(screen.getByText('Cancel'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(restoreCalled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-008: Backdrop click dismisses modal
|
||||||
|
it('FE-ADMIN-BKP-008: clicking the backdrop dismisses the restore modal', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
await user.click(screen.getAllByText('Restore')[0])
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
// Click the backdrop overlay (the fixed-position div)
|
||||||
|
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement
|
||||||
|
expect(backdrop).toBeTruthy()
|
||||||
|
fireEvent.click(backdrop!)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-009: Successful restore calls API and reloads after 1500ms
|
||||||
|
it('FE-ADMIN-BKP-009: successful restore shows toast and reloads after 1500ms', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
server.use(
|
||||||
|
http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })),
|
||||||
|
)
|
||||||
|
render(<><ToastContainer /><BackupPanel /></>)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stub reload AFTER initial data load so we don't corrupt window.location during setup
|
||||||
|
const reloadMock = vi.fn()
|
||||||
|
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
|
||||||
|
|
||||||
|
await user.click(screen.getAllByText('Restore')[0])
|
||||||
|
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByText('Yes, restore'))
|
||||||
|
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Wait for the 1500ms reload timer to fire
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1600))
|
||||||
|
expect(reloadMock).toHaveBeenCalled()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
// BKP-010: Delete backup with confirm dialog
|
||||||
|
it('FE-ADMIN-BKP-010: deletes backup after confirm and shows success toast', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
server.use(
|
||||||
|
http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })),
|
||||||
|
)
|
||||||
|
render(<><ToastContainer /><BackupPanel /></>)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
const trashBtn = Array.from(document.querySelectorAll('button')).find(
|
||||||
|
b => b.querySelector('svg.lucide-trash2'),
|
||||||
|
) as HTMLElement
|
||||||
|
expect(trashBtn).toBeTruthy()
|
||||||
|
await user.click(trashBtn!)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Backup deleted')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-011: Auto-backup enable toggle shows interval controls
|
||||||
|
it('FE-ADMIN-BKP-011: enabling auto-backup shows interval controls', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Hourly')).not.toBeInTheDocument()
|
||||||
|
await user.click(getToggleButton())
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Hourly')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Daily')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Monthly')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-012: Weekly interval shows day-of-week picker
|
||||||
|
it('FE-ADMIN-BKP-012: weekly interval shows day-of-week picker', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
server.use(
|
||||||
|
http.get('/api/backup/auto-settings', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||||
|
timezone: 'UTC',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
|
||||||
|
await user.click(screen.getByText('Weekly'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Sun')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Mon')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Sat')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Day of month')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-013: Save auto-settings calls API and shows toast
|
||||||
|
it('FE-ADMIN-BKP-013: saving auto-settings calls API and shows success toast', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
server.use(
|
||||||
|
http.get('/api/backup/auto-settings', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||||
|
timezone: 'UTC',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.put('/api/backup/auto-settings', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
render(<><ToastContainer /><BackupPanel /></>)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
await user.click(screen.getByText('Weekly'))
|
||||||
|
await waitFor(() => {
|
||||||
|
const saveBtn = screen.getByRole('button', { name: /^save$/i })
|
||||||
|
expect(saveBtn).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// BKP-014: Save button disabled until settings changed
|
||||||
|
it('FE-ADMIN-BKP-014: save button is disabled until settings are changed', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<BackupPanel />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
const saveBtn = screen.getByRole('button', { name: /^save$/i })
|
||||||
|
expect(saveBtn).toBeDisabled()
|
||||||
|
await user.click(getToggleButton())
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,8 @@ import { backupApi } from '../../api/client'
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
@@ -21,19 +23,35 @@ const KEEP_OPTIONS = [
|
|||||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||||
|
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||||
|
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||||
|
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||||
|
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||||
|
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||||
|
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
|
||||||
|
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||||
|
|
||||||
export default function BackupPanel() {
|
export default function BackupPanel() {
|
||||||
const [backups, setBackups] = useState([])
|
const [backups, setBackups] = useState([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [restoringFile, setRestoringFile] = useState(null)
|
const [restoringFile, setRestoringFile] = useState(null)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||||
|
const [serverTimezone, setServerTimezone] = useState('')
|
||||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
|
||||||
const loadBackups = async () => {
|
const loadBackups = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -51,6 +69,7 @@ export default function BackupPanel() {
|
|||||||
try {
|
try {
|
||||||
const data = await backupApi.getAutoSettings()
|
const data = await backupApi.getAutoSettings()
|
||||||
setAutoSettings(data.settings)
|
setAutoSettings(data.settings)
|
||||||
|
if (data.timezone) setServerTimezone(data.timezone)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,10 +166,12 @@ export default function BackupPanel() {
|
|||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleString(locale, {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit',
|
hour: '2-digit', minute: '2-digit',
|
||||||
})
|
}
|
||||||
|
if (serverTimezone) opts.timeZone = serverTimezone
|
||||||
|
return new Date(dateStr).toLocaleString(locale, opts)
|
||||||
} catch { return dateStr }
|
} catch { return dateStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,9 +324,11 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||||
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -331,6 +354,68 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hour picker (for daily, weekly, monthly) */}
|
||||||
|
{autoSettings.interval !== 'hourly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={String(autoSettings.hour)}
|
||||||
|
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||||
|
size="sm"
|
||||||
|
options={HOURS.map(h => {
|
||||||
|
let label: string
|
||||||
|
if (is12h) {
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||||
|
label = `${h12}:00 ${period}`
|
||||||
|
} else {
|
||||||
|
label = `${String(h).padStart(2, '0')}:00`
|
||||||
|
}
|
||||||
|
return { value: String(h), label }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of week (for weekly) */}
|
||||||
|
{autoSettings.interval === 'weekly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS_OF_WEEK.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
autoSettings.day_of_week === opt.value
|
||||||
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(opt.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of month (for monthly) */}
|
||||||
|
{autoSettings.interval === 'monthly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={String(autoSettings.day_of_month)}
|
||||||
|
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||||
|
size="sm"
|
||||||
|
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keep duration */}
|
{/* Keep duration */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// FE-COMP-CAT-001 to FE-COMP-CAT-012
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { buildUser, buildCategory } from '../../../tests/helpers/factories';
|
||||||
|
import CategoryManager from './CategoryManager';
|
||||||
|
import { ToastContainer } from '../shared/Toast';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/categories', () =>
|
||||||
|
HttpResponse.json({ categories: [] })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CategoryManager', () => {
|
||||||
|
it('FE-COMP-CAT-001: renders without crashing', () => {
|
||||||
|
render(<CategoryManager />);
|
||||||
|
expect(document.body).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-002: shows Categories title', async () => {
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('Categories');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('No categories yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-004: shows New Category button', async () => {
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('New Category');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('New Category');
|
||||||
|
await user.click(screen.getByText('New Category'));
|
||||||
|
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-006: shows existing categories from API', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/categories', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
categories: [
|
||||||
|
buildCategory({ name: 'Museum' }),
|
||||||
|
buildCategory({ name: 'Restaurant' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('Museum');
|
||||||
|
expect(screen.getByText('Restaurant')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-007: clicking Create submits POST API', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let postCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/categories', async ({ request }) => {
|
||||||
|
postCalled = true;
|
||||||
|
const body = await request.json() as Record<string, unknown>;
|
||||||
|
return HttpResponse.json({
|
||||||
|
category: buildCategory({ name: String(body.name) }),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><CategoryManager /></>);
|
||||||
|
await screen.findByText('New Category');
|
||||||
|
await user.click(screen.getByText('New Category'));
|
||||||
|
const nameInput = screen.getByPlaceholderText('Category name');
|
||||||
|
await user.type(nameInput, 'Parks');
|
||||||
|
await user.click(screen.getByText('Create'));
|
||||||
|
await waitFor(() => expect(postCalled).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/categories', () =>
|
||||||
|
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('Hotels');
|
||||||
|
// Edit button is icon-only (no title) — find all buttons and click the first action button
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
// Buttons: [New Category, ...action buttons for the category]
|
||||||
|
// The edit button is the first action button in the category row (Edit2 icon)
|
||||||
|
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||||
|
await user.click(actionBtns[0]);
|
||||||
|
// Name input pre-filled with category name
|
||||||
|
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-009: delete button triggers DELETE API', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let deleteCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/categories', () =>
|
||||||
|
HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })
|
||||||
|
),
|
||||||
|
http.delete('/api/categories/9', () => {
|
||||||
|
deleteCalled = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
render(<><ToastContainer /><CategoryManager /></>);
|
||||||
|
await screen.findByText('Parks');
|
||||||
|
// Delete button is icon-only (Trash2, no title) — find the second action button
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||||
|
await user.click(actionBtns[1]);
|
||||||
|
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-010: shows subtitle text', async () => {
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('Manage categories for places');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-011: category count is shown', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/categories', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
categories: [buildCategory({ name: 'Cat1' }), buildCategory({ name: 'Cat2' })],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('Cat1');
|
||||||
|
await screen.findByText('Cat2');
|
||||||
|
// Both categories rendered
|
||||||
|
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CAT-012: Cancel button in form hides the form', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CategoryManager />);
|
||||||
|
await screen.findByText('New Category');
|
||||||
|
await user.click(screen.getByText('New Category'));
|
||||||
|
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByText('Cancel'));
|
||||||
|
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Settings2 } from 'lucide-react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import Section from '../Settings/Section'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { MapView } from '../Map/MapView'
|
||||||
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
|
const MAP_PRESETS = [
|
||||||
|
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||||
|
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||||
|
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||||
|
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||||
|
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type Defaults = {
|
||||||
|
temperature_unit?: string
|
||||||
|
dark_mode?: string | boolean
|
||||||
|
time_format?: string
|
||||||
|
route_calculation?: boolean
|
||||||
|
blur_booking_codes?: boolean
|
||||||
|
map_tile_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionRow({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode
|
||||||
|
hint?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
|
||||||
|
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||||
|
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||||
|
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const [defaults, setDefaults] = useState<Defaults>({})
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||||
|
setDefaults(data)
|
||||||
|
setMapTileUrl(data.map_tile_url || '')
|
||||||
|
setLoaded(true)
|
||||||
|
}).catch(() => setLoaded(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const save = async (patch: Partial<Defaults>) => {
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
|
||||||
|
setDefaults(updated)
|
||||||
|
toast.success(t('admin.defaultSettings.saved'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = async (key: keyof Defaults) => {
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||||
|
setDefaults(updated)
|
||||||
|
if (key === 'map_tile_url') setMapTileUrl('')
|
||||||
|
toast.success(t('admin.defaultSettings.reset'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
||||||
|
|
||||||
|
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||||
|
isSet(field) ? (
|
||||||
|
<button
|
||||||
|
onClick={() => reset(field)}
|
||||||
|
className="text-xs ml-2"
|
||||||
|
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const mapPreviewPlaces = useMemo((): Place[] => [{
|
||||||
|
id: 1,
|
||||||
|
trip_id: 1,
|
||||||
|
name: 'Preview center',
|
||||||
|
description: null,
|
||||||
|
notes: null,
|
||||||
|
lat: 48.8566,
|
||||||
|
lng: 2.3522,
|
||||||
|
address: null,
|
||||||
|
category_id: null,
|
||||||
|
icon: null,
|
||||||
|
price: null,
|
||||||
|
currency: null,
|
||||||
|
image_url: null,
|
||||||
|
google_place_id: null,
|
||||||
|
osm_id: null,
|
||||||
|
route_geometry: null,
|
||||||
|
place_time: null,
|
||||||
|
end_time: null,
|
||||||
|
duration_minutes: null,
|
||||||
|
transport_mode: null,
|
||||||
|
website: null,
|
||||||
|
phone: null,
|
||||||
|
created_at: Date(),
|
||||||
|
}], [])
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkMode = defaults.dark_mode
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
|
||||||
|
{t('admin.defaultSettings.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Color Mode */}
|
||||||
|
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||||
|
{([
|
||||||
|
{ value: 'light', label: t('settings.light') },
|
||||||
|
{ value: 'dark', label: t('settings.dark') },
|
||||||
|
{ value: 'auto', label: t('settings.auto') },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton
|
||||||
|
key={opt.value}
|
||||||
|
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||||
|
onClick={() => save({ dark_mode: opt.value })}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
|
||||||
|
{/* Temperature */}
|
||||||
|
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||||
|
{([
|
||||||
|
{ value: 'celsius', label: '°C Celsius' },
|
||||||
|
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton
|
||||||
|
key={opt.value}
|
||||||
|
active={defaults.temperature_unit === opt.value}
|
||||||
|
onClick={() => save({ temperature_unit: opt.value })}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
|
||||||
|
{/* Time Format */}
|
||||||
|
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||||
|
{([
|
||||||
|
{ value: '24h', label: '24h (14:30)' },
|
||||||
|
{ value: '12h', label: '12h (2:30 PM)' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton
|
||||||
|
key={opt.value}
|
||||||
|
active={defaults.time_format === opt.value}
|
||||||
|
onClick={() => save({ time_format: opt.value })}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
|
||||||
|
{/* Route Calculation */}
|
||||||
|
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
||||||
|
{([
|
||||||
|
{ value: true, label: t('settings.on') || 'On' },
|
||||||
|
{ value: false, label: t('settings.off') || 'Off' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton
|
||||||
|
key={String(opt.value)}
|
||||||
|
active={defaults.route_calculation === opt.value}
|
||||||
|
onClick={() => save({ route_calculation: opt.value })}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
|
||||||
|
{/* Blur Booking Codes */}
|
||||||
|
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||||
|
{([
|
||||||
|
{ value: true, label: t('settings.on') || 'On' },
|
||||||
|
{ value: false, label: t('settings.off') || 'Off' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton
|
||||||
|
key={String(opt.value)}
|
||||||
|
active={defaults.blur_booking_codes === opt.value}
|
||||||
|
onClick={() => save({ blur_booking_codes: opt.value })}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
|
||||||
|
{/* Map Tile URL */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{t('settings.mapTemplate')}
|
||||||
|
<ResetButton field="map_tile_url" />
|
||||||
|
</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={mapTileUrl}
|
||||||
|
onChange={(value: string) => { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }}
|
||||||
|
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||||
|
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||||
|
size="sm"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mapTileUrl}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||||
|
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||||
|
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
|
||||||
|
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
{React.createElement(MapView as any, {
|
||||||
|
places: mapPreviewPlaces,
|
||||||
|
dayPlaces: [],
|
||||||
|
route: null,
|
||||||
|
routeSegments: null,
|
||||||
|
selectedPlaceId: null,
|
||||||
|
onMarkerClick: null,
|
||||||
|
onMapClick: null,
|
||||||
|
onMapContextMenu: null,
|
||||||
|
center: [48.8566, 2.3522],
|
||||||
|
zoom: 10,
|
||||||
|
tileUrl: mapTileUrl,
|
||||||
|
fitKey: null,
|
||||||
|
dayOrderMap: [],
|
||||||
|
leftWidth: 0,
|
||||||
|
rightWidth: 0,
|
||||||
|
hasInspector: false,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { buildUser } from '../../../tests/helpers/factories';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { ToastContainer } from '../shared/Toast';
|
||||||
|
import DevNotificationsPanel from './DevNotificationsPanel';
|
||||||
|
|
||||||
|
const ADMIN_USER = buildUser({ id: 1, username: 'testadmin', role: 'admin' });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
seedStore(useAuthStore, { user: ADMIN_USER, isAuthenticated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DevNotificationsPanel', () => {
|
||||||
|
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
// Wait for async data to populate conditional sections
|
||||||
|
await screen.findByText('Trip-Scoped Events');
|
||||||
|
await screen.findByText('User-Scoped Events');
|
||||||
|
expect(screen.getByText('Type Testing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
await screen.findByText('Trip-Scoped Events');
|
||||||
|
const [tripSelect] = screen.getAllByRole('combobox');
|
||||||
|
const options = Array.from(tripSelect.querySelectorAll('option'));
|
||||||
|
const labels = options.map(o => o.textContent);
|
||||||
|
expect(labels).toContain('Paris Adventure');
|
||||||
|
expect(labels).toContain('Tokyo Trip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
await screen.findByText('User-Scoped Events');
|
||||||
|
const selects = screen.getAllByRole('combobox');
|
||||||
|
// Second combobox is the user selector (first is trip selector)
|
||||||
|
const userSelect = selects[1];
|
||||||
|
const options = Array.from(userSelect.querySelectorAll('option'));
|
||||||
|
const labels = options.map(o => o.textContent ?? '');
|
||||||
|
expect(labels.some(l => l.includes('admin'))).toBe(true);
|
||||||
|
expect(labels.some(l => l.includes('alice'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
|
||||||
|
let capturedBody: Record<string, unknown> | undefined;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||||
|
capturedBody = await request.json() as Record<string, unknown>;
|
||||||
|
return HttpResponse.json({ ok: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
await screen.findByText('Type Testing');
|
||||||
|
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||||
|
await waitFor(() => expect(capturedBody).toBeDefined());
|
||||||
|
expect(capturedBody).toMatchObject({
|
||||||
|
event: 'test_simple',
|
||||||
|
scope: 'user',
|
||||||
|
targetId: ADMIN_USER.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/dev/test-notification', () =>
|
||||||
|
HttpResponse.json({ ok: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
await screen.findByText('Type Testing');
|
||||||
|
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||||
|
await screen.findByText('Sent: simple-me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/dev/test-notification', async () => {
|
||||||
|
await new Promise(() => {}); // never resolves — simulates in-flight
|
||||||
|
return HttpResponse.json({ ok: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
await screen.findByText('Type Testing');
|
||||||
|
|
||||||
|
// Fire the click but do not await — handler never resolves so sending stays true
|
||||||
|
void user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
buttons.forEach(btn => expect(btn).toBeDisabled());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/dev/test-notification', () =>
|
||||||
|
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
await screen.findByText('Type Testing');
|
||||||
|
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||||
|
await screen.findByText(/failed|error/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
|
||||||
|
let capturedBody: Record<string, unknown> | undefined;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||||
|
capturedBody = await request.json() as Record<string, unknown>;
|
||||||
|
return HttpResponse.json({ ok: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
await screen.findByText('Trip-Scoped Events');
|
||||||
|
|
||||||
|
const [tripSelect] = screen.getAllByRole('combobox');
|
||||||
|
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
|
||||||
|
o => o.textContent === 'Tokyo Trip',
|
||||||
|
)!;
|
||||||
|
const tokyoId = Number(tokyoOption.value);
|
||||||
|
|
||||||
|
await user.selectOptions(tripSelect, 'Tokyo Trip');
|
||||||
|
await user.click(screen.getByText('booking_change').closest('button')!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(capturedBody).toBeDefined());
|
||||||
|
expect(capturedBody!.targetId).toBe(tokyoId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||||
|
// Wait for user data to confirm async effects have settled
|
||||||
|
await screen.findByText('User-Scoped Events');
|
||||||
|
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { adminApi, tripsApi } from '../../api/client'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import {
|
||||||
|
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||||
|
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||||
|
Download, MapPin,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface Trip {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevNotificationsPanel(): React.ReactElement {
|
||||||
|
const toast = useToast()
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const [sending, setSending] = useState<string | null>(null)
|
||||||
|
const [trips, setTrips] = useState<Trip[]>([])
|
||||||
|
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
|
||||||
|
const [users, setUsers] = useState<AppUser[]>([])
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tripsApi.list().then(data => {
|
||||||
|
const list = (data.trips || data || []) as Trip[]
|
||||||
|
setTrips(list)
|
||||||
|
if (list.length > 0) setSelectedTripId(list[0].id)
|
||||||
|
}).catch(() => {})
|
||||||
|
adminApi.users().then(data => {
|
||||||
|
const list = (data.users || data || []) as AppUser[]
|
||||||
|
setUsers(list)
|
||||||
|
if (list.length > 0) setSelectedUserId(list[0].id)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||||
|
setSending(label)
|
||||||
|
try {
|
||||||
|
await adminApi.sendTestNotification(payload)
|
||||||
|
toast.success(`Sent: ${label}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || 'Failed')
|
||||||
|
} finally {
|
||||||
|
setSending(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTrip = trips.find(t => t.id === selectedTripId)
|
||||||
|
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||||
|
const username = user?.username || 'Admin'
|
||||||
|
const tripTitle = selectedTrip?.title || 'Test Trip'
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Btn = ({
|
||||||
|
id, label, sub, icon: Icon, color, onClick,
|
||||||
|
}: {
|
||||||
|
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={sending !== null}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
|
style={{ background: `${color}20`, color }}>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||||
|
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
|
||||||
|
</div>
|
||||||
|
{sending === id && (
|
||||||
|
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
|
||||||
|
)
|
||||||
|
|
||||||
|
const TripSelector = () => (
|
||||||
|
<select
|
||||||
|
value={selectedTripId ?? ''}
|
||||||
|
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
|
||||||
|
const UserSelector = () => (
|
||||||
|
<select
|
||||||
|
value={selectedUserId ?? ''}
|
||||||
|
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||||
|
DEV ONLY
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Notification Testing
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Type Testing</SectionTitle>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Test how each in-app notification type renders, sent to yourself.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||||
|
onClick={() => fire('simple-me', {
|
||||||
|
event: 'test_simple',
|
||||||
|
scope: 'user',
|
||||||
|
targetId: user?.id,
|
||||||
|
params: {},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||||
|
onClick={() => fire('boolean-me', {
|
||||||
|
event: 'test_boolean',
|
||||||
|
scope: 'user',
|
||||||
|
targetId: user?.id,
|
||||||
|
params: {},
|
||||||
|
inApp: {
|
||||||
|
type: 'boolean',
|
||||||
|
positiveCallback: { action: 'test_approve', payload: {} },
|
||||||
|
negativeCallback: { action: 'test_deny', payload: {} },
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||||
|
onClick={() => fire('navigate-me', {
|
||||||
|
event: 'test_navigate',
|
||||||
|
scope: 'user',
|
||||||
|
targetId: user?.id,
|
||||||
|
params: {},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||||
|
onClick={() => fire('simple-admins', {
|
||||||
|
event: 'test_simple',
|
||||||
|
scope: 'admin',
|
||||||
|
targetId: 0,
|
||||||
|
params: {},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||||
|
{trips.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||||
|
</p>
|
||||||
|
<TripSelector />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||||
|
onClick={() => selectedTripId && fire('booking_change', {
|
||||||
|
event: 'booking_change',
|
||||||
|
scope: 'trip',
|
||||||
|
targetId: selectedTripId,
|
||||||
|
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||||
|
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||||
|
event: 'trip_reminder',
|
||||||
|
scope: 'trip',
|
||||||
|
targetId: selectedTripId,
|
||||||
|
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||||
|
onClick={() => selectedTripId && fire('photos_shared', {
|
||||||
|
event: 'photos_shared',
|
||||||
|
scope: 'trip',
|
||||||
|
targetId: selectedTripId,
|
||||||
|
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||||
|
onClick={() => selectedTripId && fire('collab_message', {
|
||||||
|
event: 'collab_message',
|
||||||
|
scope: 'trip',
|
||||||
|
targetId: selectedTripId,
|
||||||
|
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||||
|
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||||
|
event: 'packing_tagged',
|
||||||
|
scope: 'trip',
|
||||||
|
targetId: selectedTripId,
|
||||||
|
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||||
|
{users.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Fires each user event to the selected recipient.
|
||||||
|
</p>
|
||||||
|
<UserSelector />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Btn
|
||||||
|
id={`trip_invite-${selectedUserId}`}
|
||||||
|
label="trip_invite"
|
||||||
|
sub="navigate · user"
|
||||||
|
icon={UserPlus}
|
||||||
|
color="#06b6d4"
|
||||||
|
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||||
|
event: 'trip_invite',
|
||||||
|
scope: 'user',
|
||||||
|
targetId: selectedUserId,
|
||||||
|
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Btn
|
||||||
|
id={`vacay_invite-${selectedUserId}`}
|
||||||
|
label="vacay_invite"
|
||||||
|
sub="navigate · user"
|
||||||
|
icon={MapPin}
|
||||||
|
color="#f97316"
|
||||||
|
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||||
|
event: 'vacay_invite',
|
||||||
|
scope: 'user',
|
||||||
|
targetId: selectedUserId,
|
||||||
|
params: { actor: username, planId: '1' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Fires to all admin users.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||||
|
onClick={() => fire('version_available', {
|
||||||
|
event: 'version_available',
|
||||||
|
scope: 'admin',
|
||||||
|
targetId: 0,
|
||||||
|
params: { version: '9.9.9-test' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
|
||||||
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import GitHubPanel from './GitHubPanel';
|
||||||
|
|
||||||
|
function buildRelease(overrides = {}) {
|
||||||
|
const id = Math.random();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
tag_name: 'v1.0.0',
|
||||||
|
name: 'Initial Release',
|
||||||
|
body: '## Changes\n- Fixed bug\n- **Bold improvement**\n- `code snippet`',
|
||||||
|
published_at: '2025-01-15T12:00:00Z',
|
||||||
|
created_at: '2025-01-15T12:00:00Z',
|
||||||
|
prerelease: false,
|
||||||
|
author: { login: 'mauriceboe' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_1 = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }),
|
||||||
|
);
|
||||||
|
const PAGE_2 = Array.from({ length: 5 }, (_, i) =>
|
||||||
|
buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([])),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitHubPanel', () => {
|
||||||
|
it('FE-ADMIN-GH-001: support link cards always render', async () => {
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Discord')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Report a Bug')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Feature Request')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-002: all support links have correct href and target=_blank', async () => {
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
const kofi = screen.getByText('Ko-fi').closest('a')!;
|
||||||
|
expect(kofi).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe');
|
||||||
|
expect(kofi).toHaveAttribute('target', '_blank');
|
||||||
|
expect(kofi).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
|
||||||
|
const bmc = screen.getByText('Buy Me a Coffee').closest('a')!;
|
||||||
|
expect(bmc).toHaveAttribute('href', 'https://buymeacoffee.com/mauriceboe');
|
||||||
|
expect(bmc).toHaveAttribute('target', '_blank');
|
||||||
|
expect(bmc).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
|
||||||
|
const discord = screen.getByText('Discord').closest('a')!;
|
||||||
|
expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
|
||||||
|
expect(discord).toHaveAttribute('target', '_blank');
|
||||||
|
expect(discord).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-003: loading spinner shown while fetching releases', () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', async () => {
|
||||||
|
await new Promise(() => {}); // never resolves
|
||||||
|
return HttpResponse.json([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
// The Loader2 spinner is rendered while loading=true
|
||||||
|
const spinner = document.querySelector('.animate-spin');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-004: error state shown on API failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () =>
|
||||||
|
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('Failed to load releases');
|
||||||
|
// Timeline should not be rendered
|
||||||
|
expect(screen.queryByText('Release History')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-005: releases render in timeline', async () => {
|
||||||
|
const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } });
|
||||||
|
const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
|
||||||
|
);
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v1.0.0');
|
||||||
|
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
|
||||||
|
// Author label
|
||||||
|
const authorLabels = screen.getAllByText(/mauriceboe/);
|
||||||
|
expect(authorLabels.length).toBeGreaterThan(0);
|
||||||
|
// Some date should be visible (non-empty)
|
||||||
|
const dateEls = document.querySelectorAll('[class*="text-"]');
|
||||||
|
const dateTexts = Array.from(dateEls).map(el => el.textContent).filter(t => t && t.match(/\d{4}/));
|
||||||
|
expect(dateTexts.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => {
|
||||||
|
const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' });
|
||||||
|
const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
|
||||||
|
);
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v2.0.0');
|
||||||
|
const latestBadges = screen.getAllByText('Latest');
|
||||||
|
expect(latestBadges).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-007: prerelease badge shown', async () => {
|
||||||
|
const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||||
|
);
|
||||||
|
render(<GitHubPanel isPrerelease={true} />);
|
||||||
|
await screen.findByText('v3.0.0-beta.1');
|
||||||
|
expect(screen.getByText('Pre-release')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-008: expand/collapse release notes', async () => {
|
||||||
|
const r = buildRelease({
|
||||||
|
id: 20,
|
||||||
|
tag_name: 'v1.5.0',
|
||||||
|
body: '- Fixed bug\n- Another fix',
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v1.5.0');
|
||||||
|
|
||||||
|
const showBtn = screen.getByText('Show details');
|
||||||
|
expect(showBtn).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Body not visible yet
|
||||||
|
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
await user.click(showBtn);
|
||||||
|
await screen.findByText('Fixed bug');
|
||||||
|
expect(screen.getByText('Hide details')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
await user.click(screen.getByText('Hide details'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Show details')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-009: release body renders markdown: lists, bold, code', async () => {
|
||||||
|
const r = buildRelease({
|
||||||
|
id: 30,
|
||||||
|
tag_name: 'v1.6.0',
|
||||||
|
body: '- list item\n- **bold text**\n- `inline code`',
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v1.6.0');
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Show details'));
|
||||||
|
await screen.findByText('list item');
|
||||||
|
|
||||||
|
// list item is inside a <li>
|
||||||
|
const listItem = screen.getByText('list item');
|
||||||
|
expect(listItem.closest('li')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Bold text rendered as <strong>
|
||||||
|
const container = document.querySelector('.mt-2.p-3.rounded-lg')!;
|
||||||
|
expect(container.querySelector('strong')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('strong')!.textContent).toBe('bold text');
|
||||||
|
|
||||||
|
// Code rendered as <code>
|
||||||
|
expect(container.querySelector('code')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('code')!.textContent).toBe('inline code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)),
|
||||||
|
);
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText(`v1.0.0`);
|
||||||
|
expect(screen.getByText('Load more')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)),
|
||||||
|
);
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v0.0.0');
|
||||||
|
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-013: release body renders plain paragraph text', async () => {
|
||||||
|
const r = buildRelease({
|
||||||
|
id: 40,
|
||||||
|
tag_name: 'v1.7.0',
|
||||||
|
body: 'This is a plain paragraph without any markdown syntax.',
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v1.7.0');
|
||||||
|
await user.click(screen.getByText('Show details'));
|
||||||
|
await screen.findByText('This is a plain paragraph without any markdown syntax.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
|
||||||
|
const r = buildRelease({
|
||||||
|
id: 41,
|
||||||
|
tag_name: 'v1.8.0',
|
||||||
|
body: '- [click here](https://example.com)',
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v1.8.0');
|
||||||
|
await user.click(screen.getByText('Show details'));
|
||||||
|
const link = await screen.findByText('click here');
|
||||||
|
expect(link.closest('a') || link.tagName.toLowerCase() === 'a' ? link : null).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-015: javascript: link is sanitized to #', async () => {
|
||||||
|
const r = buildRelease({
|
||||||
|
id: 42,
|
||||||
|
tag_name: 'v1.9.0',
|
||||||
|
body: '- [evil](javascript:alert(1))',
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v1.9.0');
|
||||||
|
await user.click(screen.getByText('Show details'));
|
||||||
|
const link = await screen.findByText('evil');
|
||||||
|
const anchor = link.closest('a') ?? link;
|
||||||
|
// The unsafe href is replaced with '#'
|
||||||
|
expect(anchor).toHaveAttribute('href', '#');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-016: support card hover effects fire without error', async () => {
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
const kofiLink = screen.getByText('Ko-fi').closest('a')!;
|
||||||
|
fireEvent.mouseEnter(kofiLink);
|
||||||
|
fireEvent.mouseLeave(kofiLink);
|
||||||
|
|
||||||
|
const discordLink = screen.getByText('Discord').closest('a')!;
|
||||||
|
fireEvent.mouseEnter(discordLink);
|
||||||
|
fireEvent.mouseLeave(discordLink);
|
||||||
|
|
||||||
|
const bugLink = screen.getByText('Report a Bug').closest('a')!;
|
||||||
|
fireEvent.mouseEnter(bugLink);
|
||||||
|
fireEvent.mouseLeave(bugLink);
|
||||||
|
|
||||||
|
const featureLink = screen.getByText('Feature Request').closest('a')!;
|
||||||
|
fireEvent.mouseEnter(featureLink);
|
||||||
|
fireEvent.mouseLeave(featureLink);
|
||||||
|
|
||||||
|
const wikiLink = screen.getByText('Wiki').closest('a')!;
|
||||||
|
fireEvent.mouseEnter(wikiLink);
|
||||||
|
fireEvent.mouseLeave(wikiLink);
|
||||||
|
|
||||||
|
const bmcLink = screen.getByText('Buy Me a Coffee').closest('a')!;
|
||||||
|
fireEvent.mouseEnter(bmcLink);
|
||||||
|
fireEvent.mouseLeave(bmcLink);
|
||||||
|
|
||||||
|
// All links still visible
|
||||||
|
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-GH-012: clicking "Load more" appends next page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/github-releases', ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const page = url.searchParams.get('page');
|
||||||
|
if (page === '2') {
|
||||||
|
return HttpResponse.json(PAGE_2);
|
||||||
|
}
|
||||||
|
return HttpResponse.json(PAGE_1);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GitHubPanel />);
|
||||||
|
await screen.findByText('v1.0.0');
|
||||||
|
|
||||||
|
// All 10 items from page 1 visible
|
||||||
|
expect(screen.getAllByText(/v1\.\d\.0/).length).toBe(10);
|
||||||
|
|
||||||
|
// Click Load more
|
||||||
|
await user.click(screen.getByText('Load more'));
|
||||||
|
|
||||||
|
// Wait for page 2 items to appear
|
||||||
|
await screen.findByText('v0.0.0');
|
||||||
|
|
||||||
|
// Total: 10 from page 1 + 5 from page 2 = 15
|
||||||
|
const tagEls = screen.getAllByText(/^v[01]\.\d\.0$/);
|
||||||
|
expect(tagEls.length).toBe(15);
|
||||||
|
|
||||||
|
// Load more should be hidden (PAGE_2 < 10)
|
||||||
|
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,31 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
|
|
||||||
const REPO = 'mauriceboe/NOMAD'
|
const REPO = 'mauriceboe/TREK'
|
||||||
const PER_PAGE = 10
|
const PER_PAGE = 10
|
||||||
|
|
||||||
export default function GitHubPanel() {
|
interface GithubRelease {
|
||||||
|
id: number
|
||||||
|
prerelease: boolean
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
const [releases, setReleases] = useState([])
|
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [expanded, setExpanded] = useState({})
|
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
|
||||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/auth/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||||
const data = res.data
|
const data = Array.isArray(res.data) ? res.data : []
|
||||||
setReleases(prev => append ? [...prev, ...data] : data)
|
setReleases(prev => append ? [...prev, ...data] : data)
|
||||||
setHasMore(data.length === PER_PAGE)
|
setHasMore(data.length === PER_PAGE)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -72,11 +78,15 @@ export default function GitHubPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
const inlineFormat = (text) => {
|
const inlineFormat = (text) => {
|
||||||
return text
|
return escapeHtml(text)
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||||
|
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||||
|
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -115,12 +125,12 @@ export default function GitHubPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Support cards */}
|
{/* Support cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<a
|
<a
|
||||||
href="https://ko-fi.com/mauriceboe"
|
href="https://ko-fi.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -138,7 +148,7 @@ export default function GitHubPanel() {
|
|||||||
href="https://buymeacoffee.com/mauriceboe"
|
href="https://buymeacoffee.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -152,6 +162,81 @@ export default function GitHubPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/NhZBDSd4qW"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/mauriceboe/TREK/wiki"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading / Error / Releases */}
|
{/* Loading / Error / Releases */}
|
||||||
@@ -194,7 +279,7 @@ export default function GitHubPanel() {
|
|||||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||||
|
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{releases.map((release, idx) => {
|
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||||
const isLatest = idx === 0
|
const isLatest = idx === 0
|
||||||
const isExpanded = expanded[release.id]
|
const isExpanded = expanded[release.id]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import PackingTemplateManager from './PackingTemplateManager';
|
||||||
|
import { ToastContainer } from '../shared/Toast';
|
||||||
|
|
||||||
|
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' }
|
||||||
|
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' }
|
||||||
|
|
||||||
|
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 }
|
||||||
|
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 }
|
||||||
|
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PackingTemplateManager', () => {
|
||||||
|
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
return HttpResponse.json({ templates: [] });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-002: shows empty state when no templates', async () => {
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('No templates created yet');
|
||||||
|
expect(screen.queryAllByRole('button', { name: /chevron/i })).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-003: template list renders names and counts', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1, tmpl2] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||||
|
// tmpl1 has 2 categories and 5 items
|
||||||
|
expect(screen.getByText(/2 categories · 5 items/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-004: clicking "+" shows create input', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('No templates created yet');
|
||||||
|
const createBtn = screen.getByRole('button', { name: /new template/i });
|
||||||
|
await user.click(createBtn);
|
||||||
|
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-005: creates template on Enter and shows success toast', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let postCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/packing-templates', async () => {
|
||||||
|
postCalled = true;
|
||||||
|
return HttpResponse.json({ template: { id: 99, name: 'New Template' } });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||||
|
await screen.findByText('No templates created yet');
|
||||||
|
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||||
|
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||||
|
await user.type(input, 'New Template{Enter}');
|
||||||
|
await waitFor(() => expect(postCalled).toBe(true));
|
||||||
|
// "New Template" may appear both as the button label and the new list item
|
||||||
|
await waitFor(() => expect(screen.getAllByText('New Template').length).toBeGreaterThanOrEqual(1));
|
||||||
|
await screen.findByText('Template created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-006: Escape dismisses create input without API call', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let postCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/admin/packing-templates', async () => {
|
||||||
|
postCalled = true;
|
||||||
|
return HttpResponse.json({ template: { id: 99, name: 'Should Not Appear' } });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('No templates created yet');
|
||||||
|
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||||
|
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||||
|
await user.type(input, 'Test{Escape}');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(postCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-007: expanding a template loads and displays its categories and items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('Clothing');
|
||||||
|
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-008: collapsing an expanded template hides its content', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('Clothing');
|
||||||
|
// Collapse by clicking again
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-009: deleting a template removes it from the list and shows toast', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let deleteCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1, tmpl2] })
|
||||||
|
),
|
||||||
|
http.delete('/api/admin/packing-templates/1', () => {
|
||||||
|
deleteCalled = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find all Trash2 (delete) buttons — there are 2 (one per template)
|
||||||
|
const deleteButtons = screen.getAllByRole('button').filter(b =>
|
||||||
|
b.className.includes('hover:bg-red-50') || b.querySelector('svg')
|
||||||
|
);
|
||||||
|
// Click the delete button for "Beach Trip" (first template row's trash button)
|
||||||
|
// The buttons layout in each row: [chevron, edit, delete]
|
||||||
|
// We find rows first
|
||||||
|
const beachTripRow = screen.getByText('Beach Trip').closest('div');
|
||||||
|
const trashBtn = beachTripRow!.parentElement!.querySelector('button.hover\\:bg-red-50') as HTMLElement | null;
|
||||||
|
if (trashBtn) {
|
||||||
|
await user.click(trashBtn);
|
||||||
|
} else {
|
||||||
|
// Fallback: find all red-hover buttons and click first
|
||||||
|
const allBtns = screen.getAllByRole('button');
|
||||||
|
const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
|
||||||
|
await user.click(redBtns[0]);
|
||||||
|
}
|
||||||
|
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||||
|
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||||
|
await screen.findByText('Template deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-010: renaming a template inline updates the list', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let putCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.put('/api/admin/packing-templates/1', async () => {
|
||||||
|
putCalled = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
|
||||||
|
// Find the Edit2 button on the template row
|
||||||
|
const beachTripText = screen.getByText('Beach Trip');
|
||||||
|
const row = beachTripText.closest('div')!.parentElement!;
|
||||||
|
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||||
|
if (editBtn) {
|
||||||
|
await user.click(editBtn);
|
||||||
|
} else {
|
||||||
|
// Fallback: find all slate-100-hover buttons
|
||||||
|
const allBtns = screen.getAllByRole('button');
|
||||||
|
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||||
|
await user.click(editBtns[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('Beach Trip');
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'Summer Packing{Enter}');
|
||||||
|
await waitFor(() => expect(putCalled).toBe(true));
|
||||||
|
await screen.findByText('Summer Packing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-011: adding a category to an expanded template', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [], items: [] })
|
||||||
|
),
|
||||||
|
http.post('/api/admin/packing-templates/1/categories', async () =>
|
||||||
|
HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
// Wait for expanded state (Add category button should appear)
|
||||||
|
await screen.findByText('Add category');
|
||||||
|
await user.click(screen.getByText('Add category'));
|
||||||
|
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||||
|
await user.type(catInput, 'Electronics{Enter}');
|
||||||
|
await screen.findByText('Electronics');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [cat1], items: [] })
|
||||||
|
),
|
||||||
|
http.post('/api/admin/packing-templates/1/categories/10/items', async () =>
|
||||||
|
HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('Clothing');
|
||||||
|
|
||||||
|
// Click the "+" button on the Clothing category row
|
||||||
|
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||||
|
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
|
||||||
|
await user.click(addItemBtn);
|
||||||
|
|
||||||
|
const itemInput = screen.getByPlaceholderText('Item name');
|
||||||
|
await user.type(itemInput, 'Sandals');
|
||||||
|
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
|
||||||
|
await user.type(itemInput, '{Enter}');
|
||||||
|
await screen.findByText('Sandals');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [cat1], items: [] })
|
||||||
|
),
|
||||||
|
http.put('/api/admin/packing-templates/1/categories/10', async () =>
|
||||||
|
HttpResponse.json({ success: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('Clothing');
|
||||||
|
|
||||||
|
// Find the Edit2 button in the Clothing category header
|
||||||
|
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||||
|
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
|
||||||
|
b => b.className.includes('hover:text-slate-700')
|
||||||
|
);
|
||||||
|
// Second button (after Plus) is Edit2
|
||||||
|
await user.click(editBtns[1]);
|
||||||
|
|
||||||
|
const catInput = screen.getByDisplayValue('Clothing');
|
||||||
|
await user.clear(catInput);
|
||||||
|
await user.type(catInput, 'Shoes{Enter}');
|
||||||
|
await screen.findByText('Shoes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||||
|
),
|
||||||
|
http.delete('/api/admin/packing-templates/1/categories/10', () =>
|
||||||
|
HttpResponse.json({ success: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('Clothing');
|
||||||
|
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find the Trash2 button in the Clothing category header
|
||||||
|
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||||
|
const trashBtn = clothingHeader.querySelector('button.hover\\:text-red-500') as HTMLElement;
|
||||||
|
await user.click(trashBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-015: renaming an item inline updates its name', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [cat1], items: [item1] })
|
||||||
|
),
|
||||||
|
http.put('/api/admin/packing-templates/1/items/100', async () =>
|
||||||
|
HttpResponse.json({ success: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('T-shirt');
|
||||||
|
|
||||||
|
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
|
||||||
|
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||||
|
const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
|
||||||
|
b => b.className.includes('opacity-0')
|
||||||
|
) as HTMLElement | undefined;
|
||||||
|
if (editBtn) {
|
||||||
|
await user.click(editBtn);
|
||||||
|
} else {
|
||||||
|
// Directly click the first button in the item row
|
||||||
|
const btns = itemRow.querySelectorAll('button');
|
||||||
|
await user.click(btns[0] as HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('T-shirt');
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'Tank Top{Enter}');
|
||||||
|
await screen.findByText('Tank Top');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||||
|
),
|
||||||
|
http.delete('/api/admin/packing-templates/1/items/100', () =>
|
||||||
|
HttpResponse.json({ success: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('T-shirt');
|
||||||
|
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find the Trash2 button in the T-shirt row
|
||||||
|
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||||
|
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
|
||||||
|
b => b.className.includes('opacity-0')
|
||||||
|
);
|
||||||
|
// Second opacity-0 button is the delete (trash) button
|
||||||
|
const trashBtn = trashBtns[1] || trashBtns[0];
|
||||||
|
await user.click(trashBtn as HTMLElement);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-017: Escape cancels add category without saving', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let postCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [], items: [] })
|
||||||
|
),
|
||||||
|
http.post('/api/admin/packing-templates/1/categories', async () => {
|
||||||
|
postCalled = true;
|
||||||
|
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Ignored', sort_order: 1 } });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('Add category');
|
||||||
|
await user.click(screen.getByText('Add category'));
|
||||||
|
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||||
|
await user.type(catInput, 'Test{Escape}');
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(postCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-018: Escape cancels add item without saving', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let postCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.get('/api/admin/packing-templates/1', () =>
|
||||||
|
HttpResponse.json({ categories: [cat1], items: [] })
|
||||||
|
),
|
||||||
|
http.post('/api/admin/packing-templates/1/categories/10/items', async () => {
|
||||||
|
postCalled = true;
|
||||||
|
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Ignored', sort_order: 2 } });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
await user.click(screen.getByText('Beach Trip'));
|
||||||
|
await screen.findByText('Clothing');
|
||||||
|
|
||||||
|
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||||
|
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
|
||||||
|
await user.click(addItemBtn);
|
||||||
|
|
||||||
|
const itemInput = screen.getByPlaceholderText('Item name');
|
||||||
|
await user.type(itemInput, 'Test{Escape}');
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(postCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-019: Escape cancels template rename without saving', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let putCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/packing-templates', () =>
|
||||||
|
HttpResponse.json({ templates: [tmpl1] })
|
||||||
|
),
|
||||||
|
http.put('/api/admin/packing-templates/1', async () => {
|
||||||
|
putCalled = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('Beach Trip');
|
||||||
|
|
||||||
|
const beachTripText = screen.getByText('Beach Trip');
|
||||||
|
const row = beachTripText.closest('div')!.parentElement!;
|
||||||
|
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||||
|
if (editBtn) {
|
||||||
|
await user.click(editBtn);
|
||||||
|
} else {
|
||||||
|
const allBtns = screen.getAllByRole('button');
|
||||||
|
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||||
|
await user.click(editBtns[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('Beach Trip');
|
||||||
|
await user.type(input, '{Escape}');
|
||||||
|
await waitFor(() => expect(screen.queryByDisplayValue('Beach Trip')).not.toBeInTheDocument());
|
||||||
|
expect(putCalled).toBe(false);
|
||||||
|
// Original name should be restored
|
||||||
|
expect(screen.getByText('Beach Trip')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PKG-020: X button on create template input dismisses it', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PackingTemplateManager />);
|
||||||
|
await screen.findByText('No templates created yet');
|
||||||
|
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||||
|
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find the X (cancel) button in the create row — it's the last button in the create row
|
||||||
|
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
|
||||||
|
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
|
||||||
|
await user.click(cancelBtn);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
// FE-ADMIN-PERM-001 to FE-ADMIN-PERM-010
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import { ToastContainer } from '../shared/Toast';
|
||||||
|
import PermissionsPanel from './PermissionsPanel';
|
||||||
|
|
||||||
|
// ── Fixture ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ALLOWED = ['admin', 'trip_owner', 'trip_member', 'everybody'] as const;
|
||||||
|
|
||||||
|
function buildPermission(key: string, level = 'trip_member', defaultLevel = 'trip_member') {
|
||||||
|
return { key, level, defaultLevel, allowedLevels: [...ALLOWED] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_PERMISSIONS = [
|
||||||
|
buildPermission('trip_create'),
|
||||||
|
buildPermission('trip_edit'),
|
||||||
|
buildPermission('trip_delete'),
|
||||||
|
buildPermission('trip_archive'),
|
||||||
|
buildPermission('trip_cover_upload'),
|
||||||
|
buildPermission('member_manage'),
|
||||||
|
buildPermission('file_upload'),
|
||||||
|
buildPermission('file_edit'),
|
||||||
|
buildPermission('file_delete'),
|
||||||
|
buildPermission('place_edit'),
|
||||||
|
buildPermission('day_edit'),
|
||||||
|
buildPermission('reservation_edit'),
|
||||||
|
buildPermission('budget_edit'),
|
||||||
|
buildPermission('packing_edit'),
|
||||||
|
buildPermission('collab_edit'),
|
||||||
|
buildPermission('share_manage'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
return render(
|
||||||
|
<>
|
||||||
|
<ToastContainer />
|
||||||
|
<PermissionsPanel />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
// Override the default handler (returns object) with correct array shape
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/permissions', () =>
|
||||||
|
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PermissionsPanel', () => {
|
||||||
|
it('FE-ADMIN-PERM-001: loading spinner renders before data arrives', () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/permissions', async () => {
|
||||||
|
await new Promise(() => {}); // never resolves
|
||||||
|
return HttpResponse.json({ permissions: [] });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderPanel();
|
||||||
|
const spinner = document.querySelector('.animate-spin');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
// The form content (category headings) should not be present
|
||||||
|
expect(screen.queryByText('Trip Management')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-002: permission categories and actions render after load', async () => {
|
||||||
|
renderPanel();
|
||||||
|
// Wait until loading is done — a category heading appears
|
||||||
|
await screen.findByText('Trip Management');
|
||||||
|
expect(screen.getByText('Member Management')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Content & Schedule')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Budget, Packing & Collaboration')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create trips')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Add / remove members')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-003: "customized" badge visible when value differs from default', async () => {
|
||||||
|
const perms = [
|
||||||
|
buildPermission('trip_create', 'admin', 'trip_member'), // level ≠ default → badge
|
||||||
|
buildPermission('trip_edit', 'trip_member', 'trip_member'), // level === default → no badge
|
||||||
|
];
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/permissions', () =>
|
||||||
|
HttpResponse.json({ permissions: perms }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByText('Trip Management');
|
||||||
|
// Badge should appear once (for trip_create)
|
||||||
|
expect(screen.getByText('customized')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('customized')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-004: Save button is disabled until a value changes', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByText('Trip Management');
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Open the first CustomSelect trigger (shows current level "Trip members")
|
||||||
|
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||||
|
await user.click(triggers[0]);
|
||||||
|
|
||||||
|
// Pick an option different from the current one (current is trip_member → pick admin)
|
||||||
|
const adminOption = await screen.findByText('Admin only');
|
||||||
|
await user.click(adminOption);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(saveButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-005: changing a value marks form dirty and enables Save', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByText('Trip Management');
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Open first CustomSelect dropdown and select a different option
|
||||||
|
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||||
|
await user.click(triggers[0]);
|
||||||
|
const adminOption = await screen.findByText('Admin only');
|
||||||
|
await user.click(adminOption);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(saveButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-006: Reset button restores values to defaultLevel and enables Save', async () => {
|
||||||
|
const perms = [
|
||||||
|
buildPermission('trip_create', 'admin', 'trip_member'), // customized
|
||||||
|
...SAMPLE_PERMISSIONS.filter(p => p.key !== 'trip_create'),
|
||||||
|
];
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/permissions', () =>
|
||||||
|
HttpResponse.json({ permissions: perms }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByText('Trip Management');
|
||||||
|
|
||||||
|
// Customized badge should be visible
|
||||||
|
expect(screen.getByText('customized')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||||
|
const resetButton = screen.getByRole('button', { name: /Reset to defaults/i });
|
||||||
|
|
||||||
|
await user.click(resetButton);
|
||||||
|
|
||||||
|
// Badge should disappear (value back to defaultLevel)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('customized')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save should be enabled (handleReset sets dirty=true)
|
||||||
|
expect(saveButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-007: successful save calls PUT and shows success toast', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put('/api/admin/permissions', () =>
|
||||||
|
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByText('Trip Management');
|
||||||
|
|
||||||
|
// Dirty the form
|
||||||
|
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||||
|
await user.click(triggers[0]);
|
||||||
|
const adminOption = await screen.findByText('Admin only');
|
||||||
|
await user.click(adminOption);
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||||
|
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||||
|
await user.click(saveButton);
|
||||||
|
|
||||||
|
await screen.findByText('Permission settings saved');
|
||||||
|
// After successful save, dirty is cleared → Save disabled again
|
||||||
|
await waitFor(() => expect(saveButton).toBeDisabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-008: failed save shows error toast and keeps Save enabled', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put('/api/admin/permissions', () =>
|
||||||
|
HttpResponse.json({ error: 'server error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByText('Trip Management');
|
||||||
|
|
||||||
|
// Dirty the form
|
||||||
|
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||||
|
await user.click(triggers[0]);
|
||||||
|
const adminOption = await screen.findByText('Admin only');
|
||||||
|
await user.click(adminOption);
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||||
|
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||||
|
await user.click(saveButton);
|
||||||
|
|
||||||
|
await screen.findByText('Error');
|
||||||
|
// Dirty unchanged → Save stays enabled
|
||||||
|
expect(saveButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-009: Save button is disabled while save is in-flight', async () => {
|
||||||
|
let resolvePut!: () => void;
|
||||||
|
server.use(
|
||||||
|
http.put('/api/admin/permissions', () =>
|
||||||
|
new Promise<Response>(resolve => {
|
||||||
|
resolvePut = () =>
|
||||||
|
resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByText('Trip Management');
|
||||||
|
|
||||||
|
// Dirty the form
|
||||||
|
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||||
|
await user.click(triggers[0]);
|
||||||
|
const adminOption = await screen.findByText('Admin only');
|
||||||
|
await user.click(adminOption);
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||||
|
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||||
|
await user.click(saveButton);
|
||||||
|
|
||||||
|
// In-flight: button should be disabled and show Loader2 spinner
|
||||||
|
await waitFor(() => expect(saveButton).toBeDisabled());
|
||||||
|
const loader = saveButton.querySelector('.animate-spin');
|
||||||
|
expect(loader).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Resolve the request
|
||||||
|
resolvePut();
|
||||||
|
await screen.findByText('Permission settings saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PERM-010: load failure shows error toast', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/permissions', () =>
|
||||||
|
HttpResponse.json({ error: 'server error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPanel();
|
||||||
|
await screen.findByText('Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { useEffect, useState, useMemo } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { Save, Loader2, RotateCcw } from 'lucide-react'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
|
||||||
|
interface PermissionEntry {
|
||||||
|
key: string
|
||||||
|
level: PermissionLevel
|
||||||
|
defaultLevel: PermissionLevel
|
||||||
|
allowedLevels: PermissionLevel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<string, string> = {
|
||||||
|
admin: 'perm.level.admin',
|
||||||
|
trip_owner: 'perm.level.tripOwner',
|
||||||
|
trip_member: 'perm.level.tripMember',
|
||||||
|
everybody: 'perm.level.everybody',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
|
||||||
|
{ id: 'members', keys: ['member_manage'] },
|
||||||
|
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||||
|
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||||
|
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function PermissionsPanel(): React.ReactElement {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const [entries, setEntries] = useState<PermissionEntry[]>([])
|
||||||
|
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPermissions()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadPermissions = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getPermissions()
|
||||||
|
setEntries(data.permissions)
|
||||||
|
const vals: Record<string, PermissionLevel> = {}
|
||||||
|
for (const p of data.permissions) vals[p.key] = p.level
|
||||||
|
setValues(vals)
|
||||||
|
setDirty(false)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (key: string, level: PermissionLevel) => {
|
||||||
|
setValues(prev => ({ ...prev, [key]: level }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.updatePermissions(values)
|
||||||
|
if (data.permissions) {
|
||||||
|
usePermissionsStore.getState().setPermissions(data.permissions)
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
toast.success(t('perm.saved'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
const defaults: Record<string, PermissionLevel> = {}
|
||||||
|
for (const p of entries) defaults[p.key] = p.defaultLevel
|
||||||
|
setValues(defaults)
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={saving}
|
||||||
|
title={t('perm.resetDefaults')}
|
||||||
|
aria-label={t('perm.resetDefaults')}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !dirty}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<div key={cat.id} className="px-6 py-4">
|
||||||
|
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||||
|
{t(`perm.cat.${cat.id}`)}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cat.keys.map(key => {
|
||||||
|
const entry = entryMap.get(key)
|
||||||
|
if (!entry) return null
|
||||||
|
const currentLevel = values[key] || entry.defaultLevel
|
||||||
|
const isDefault = currentLevel === entry.defaultLevel
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isDefault && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||||
|
{t('perm.customized')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CustomSelect
|
||||||
|
value={currentLevel}
|
||||||
|
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||||
|
options={entry.allowedLevels.map(l => ({
|
||||||
|
value: l,
|
||||||
|
label: t(LEVEL_LABELS[l] || l),
|
||||||
|
}))}
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
||||||
|
import BudgetPanel from './BudgetPanel';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
// Settlement and per-person APIs needed by BudgetPanel
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/:id/budget/settlement', () =>
|
||||||
|
HttpResponse.json({ balances: [], flows: [] })
|
||||||
|
),
|
||||||
|
http.get('/api/trips/:id/budget/per-person', () =>
|
||||||
|
HttpResponse.json({ summary: [] })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BudgetPanel', () => {
|
||||||
|
it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('No budget created yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText(/Create categories and entries/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByPlaceholderText('Enter category name...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Hotel Paris');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-005: renders category section header', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Transport');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Name');
|
||||||
|
await screen.findByText('Total');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Budget');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('CSV');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByPlaceholderText('New Entry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
|
||||||
|
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||||
|
const body = await request.json() as Record<string, unknown>;
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' });
|
||||||
|
return HttpResponse.json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
const nameInput = await screen.findByPlaceholderText('New Entry');
|
||||||
|
await user.type(nameInput, 'Restaurant Dinner');
|
||||||
|
const addBtn = screen.getByTitle('Add Reservation');
|
||||||
|
await user.click(addBtn);
|
||||||
|
await screen.findByText('Restaurant Dinner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Test Item');
|
||||||
|
// Delete button has title="Delete"
|
||||||
|
expect(screen.getByTitle('Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-012: delete item removes it from the UI', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = buildBudgetItem({ id: 42, trip_id: 1, category: 'Food', name: 'Item To Delete' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.delete('/api/trips/1/budget/42', () => HttpResponse.json({ success: true }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Item To Delete');
|
||||||
|
await user.click(screen.getByTitle('Delete'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Item To Delete')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => {
|
||||||
|
const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' });
|
||||||
|
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Hotel A');
|
||||||
|
await screen.findByText('Hotel B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
|
||||||
|
const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' });
|
||||||
|
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Transport');
|
||||||
|
await screen.findByText('Hotels');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
// Component renders even in empty state
|
||||||
|
await screen.findByText('No budget created yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Misc');
|
||||||
|
// Row exists - EUR formatting would appear in values
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('ToDelete');
|
||||||
|
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => {
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByPlaceholderText('New Entry');
|
||||||
|
// The add button is present
|
||||||
|
expect(screen.getByTitle('Add Reservation')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-019: add item with Enter key submits the form', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
|
||||||
|
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||||
|
const body = await request.json() as Record<string, unknown>;
|
||||||
|
const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' });
|
||||||
|
return HttpResponse.json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
const nameInput = await screen.findByPlaceholderText('New Entry');
|
||||||
|
await user.type(nameInput, 'Pizza{Enter}');
|
||||||
|
await screen.findByText('Pizza');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} tripMembers={[]} />);
|
||||||
|
await screen.findByText('No budget created yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Old Name');
|
||||||
|
await user.click(screen.getByText('Old Name'));
|
||||||
|
expect(screen.getByDisplayValue('Old Name')).toBeInTheDocument();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByDisplayValue('Old Name')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-022: inline edit name cell — saving new name calls PUT API', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = { ...buildBudgetItem({ id: 10, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
|
||||||
|
let putCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.put('/api/trips/1/budget/10', async ({ request }) => {
|
||||||
|
const b = await request.json() as Record<string, unknown>;
|
||||||
|
putCalled = true;
|
||||||
|
return HttpResponse.json({ item: { ...item, name: b.name } });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Old Name');
|
||||||
|
await user.click(screen.getByText('Old Name'));
|
||||||
|
const input = screen.getByDisplayValue('Old Name');
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'New Name');
|
||||||
|
await user.tab();
|
||||||
|
await waitFor(() => expect(putCalled).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => {
|
||||||
|
const item = { ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }), total_price: 45.5 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Dinner');
|
||||||
|
// The formatted number appears in the InlineEditCell for total price (and grand total card)
|
||||||
|
expect(screen.getAllByText('45.50').length).toBeGreaterThan(0);
|
||||||
|
// The currency symbol appears (in category subtotal or grand total card)
|
||||||
|
expect(screen.getAllByText(/€/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = { ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }), total_price: 200 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findAllByText('Flights');
|
||||||
|
await screen.findByText('Flight to Paris');
|
||||||
|
await user.click(screen.getByTitle('Delete Category'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryAllByText('Flights').length).toBe(0);
|
||||||
|
expect(screen.queryByText('Flight to Paris')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-025: CSV export button triggers download via URL.createObjectURL', async () => {
|
||||||
|
const createObjectURL = vi.fn(() => 'blob:test');
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('CSV');
|
||||||
|
await user.click(screen.getByText('CSV'));
|
||||||
|
expect(createObjectURL).toHaveBeenCalled();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => {
|
||||||
|
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 };
|
||||||
|
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Lunch');
|
||||||
|
// The category header shows subtotal formatted as "50.00 €" (also appears in pie legend)
|
||||||
|
expect(screen.getAllByText('50.00 €').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByPlaceholderText('Enter category name...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.post('/api/trips/1/budget', () =>
|
||||||
|
HttpResponse.json({ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
const input = await screen.findByPlaceholderText('Enter category name...');
|
||||||
|
await user.type(input, 'Souvenirs{Enter}');
|
||||||
|
await screen.findByText('Souvenirs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 100 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
balances: [
|
||||||
|
{ user_id: 1, username: 'alice', balance: -10, avatar_url: null },
|
||||||
|
{ user_id: 2, username: 'bob', balance: 10, avatar_url: null },
|
||||||
|
],
|
||||||
|
flows: [
|
||||||
|
{ from: { username: 'alice', avatar_url: null }, to: { username: 'bob', avatar_url: null }, amount: 10 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const tripMembers = [
|
||||||
|
{ id: 1, username: 'alice', avatar_url: null },
|
||||||
|
{ id: 2, username: 'bob', avatar_url: null },
|
||||||
|
];
|
||||||
|
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||||
|
await screen.findByText('Dinner');
|
||||||
|
// Click the settlement toggle button (role button with name containing "settlement")
|
||||||
|
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
|
||||||
|
await user.click(settlementBtn);
|
||||||
|
// alice and bob should appear in balances section
|
||||||
|
await screen.findByText('alice');
|
||||||
|
await screen.findByText('bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => {
|
||||||
|
const item = {
|
||||||
|
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
|
||||||
|
total_price: 75,
|
||||||
|
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
|
||||||
|
};
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/summary/per-person', () =>
|
||||||
|
HttpResponse.json({ summary: [{ user_id: 1, username: 'testuser', avatar_url: null, total_assigned: 75 }] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const tripMembers = [
|
||||||
|
{ id: 1, username: 'testuser', avatar_url: null },
|
||||||
|
{ id: 2, username: 'other', avatar_url: null },
|
||||||
|
];
|
||||||
|
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||||
|
await screen.findByText('Shared Dinner');
|
||||||
|
await screen.findByText('testuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
|
||||||
|
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 };
|
||||||
|
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Flight');
|
||||||
|
await screen.findByText('Hotel');
|
||||||
|
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
|
||||||
|
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||||
|
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
|
||||||
|
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||||
|
// Use a user with id != 1 so they're not the owner
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Read Only Item');
|
||||||
|
// In read-only mode the Delete button should not be visible
|
||||||
|
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||||
|
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Train');
|
||||||
|
// expense_date is rendered as plain text in read-only mode
|
||||||
|
await screen.findByText('2025-06-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
balances: [
|
||||||
|
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
|
||||||
|
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
|
||||||
|
],
|
||||||
|
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
|
||||||
|
);
|
||||||
|
const tripMembers = [
|
||||||
|
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
|
||||||
|
{ id: 2, username: 'bob', avatar_url: null },
|
||||||
|
];
|
||||||
|
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||||
|
await screen.findByText('Lunch');
|
||||||
|
// Trigger settlement display
|
||||||
|
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
|
||||||
|
await user.click(settlementBtn);
|
||||||
|
await screen.findByText('alice');
|
||||||
|
// Avatar image should be rendered for alice
|
||||||
|
const avatarImg = screen.getAllByRole('img');
|
||||||
|
expect(avatarImg.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||||
|
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||||
|
);
|
||||||
|
render(<BudgetPanel tripId={1} />);
|
||||||
|
await screen.findByText('Snack');
|
||||||
|
// When expense_date is null, the fallback '—' is shown
|
||||||
|
const dashes = screen.getAllByText('—');
|
||||||
|
expect(dashes.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
|||||||
|
// FE-COMP-CHAT-001 to FE-COMP-CHAT-012
|
||||||
|
// jsdom doesn't implement scrollTo — mock it to prevent uncaught exceptions from CollabChat's scrollToBottom
|
||||||
|
beforeAll(() => {
|
||||||
|
Element.prototype.scrollTo = vi.fn() as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
// CollabChat uses addListener/removeListener from websocket — extend the global mock
|
||||||
|
vi.mock('../../api/websocket', () => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
getSocketId: vi.fn(() => null),
|
||||||
|
setRefetchCallback: vi.fn(),
|
||||||
|
setPreReconnectHook: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen, waitFor, act, fireEvent } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||||
|
import CollabChat from './CollabChat';
|
||||||
|
import { addListener } from '../../api/websocket';
|
||||||
|
|
||||||
|
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
tripId: 1,
|
||||||
|
currentUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({ messages: [], total: 0 })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CollabChat', () => {
|
||||||
|
it('FE-COMP-CHAT-001: renders without crashing', () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
expect(document.body).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-002: shows empty state when no messages', async () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-003: shows message input placeholder', async () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
// Wait for loading to complete
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-004: shows send button (ArrowUp icon, no title)', async () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
// Send button has no title attr — verify buttons exist in the toolbar area
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-005: shows existing messages from API', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser',
|
||||||
|
avatar_url: null, text: 'Hello world!', created_at: '2025-06-01T10:00:00.000Z',
|
||||||
|
reactions: {}, reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Hello world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-006: typing in input updates text field', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
const input = screen.getByPlaceholderText('Type a message...');
|
||||||
|
await user.type(input, 'Test message');
|
||||||
|
expect((input as HTMLTextAreaElement).value).toBe('Test message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-007: submitting message via Enter calls POST API', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let postCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/collab/messages', async () => {
|
||||||
|
postCalled = true;
|
||||||
|
return HttpResponse.json({
|
||||||
|
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
|
||||||
|
avatar_url: null, text: 'New message', created_at: new Date().toISOString(),
|
||||||
|
reactions: {}, reply_to: null, deleted: false, edited: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
const input = screen.getByPlaceholderText('Type a message...');
|
||||||
|
// Enter key sends message (Shift+Enter = newline, Enter = send)
|
||||||
|
await user.type(input, 'New message{Enter}');
|
||||||
|
await waitFor(() => expect(postCalled).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-008: message input area is present after loading', async () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-009: shows hint text in empty state', async () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText(/Share ideas, plans/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-010: chat container renders', () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
expect(document.body.children.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-011: multiple messages all render', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [
|
||||||
|
{ id: 1, trip_id: 1, user_id: 1, username: 'testuser', avatar_url: null, text: 'First message', created_at: '2025-06-01T10:00:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
|
||||||
|
{ id: 2, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null, text: 'Second message', created_at: '2025-06-01T10:01:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('First message');
|
||||||
|
expect(screen.getByText('Second message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-012: shows emoji button in the toolbar', async () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
// Emoji button is a button in the toolbar
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-013: date separator shows "Today" for messages sent today', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'Hello world!', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Hello world!');
|
||||||
|
expect(screen.getByText('Today')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-014: Shift+Enter inserts a newline instead of sending', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let postCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/collab/messages', async () => {
|
||||||
|
postCalled = true;
|
||||||
|
return HttpResponse.json({});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
const input = screen.getByPlaceholderText('Type a message...');
|
||||||
|
await user.click(input);
|
||||||
|
await user.type(input, 'Line1');
|
||||||
|
await user.keyboard('{Shift>}{Enter}{/Shift}');
|
||||||
|
await user.type(input, 'Line2');
|
||||||
|
expect((input as HTMLTextAreaElement).value).toContain('\n');
|
||||||
|
expect(postCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-015: deleted message shows fallback text', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'some text', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: true, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-017: reaction badge renders for a message with reactions', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'React to me', created_at: new Date().toISOString(),
|
||||||
|
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
|
||||||
|
reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('React to me');
|
||||||
|
// ReactionBadge renders a button containing a TwemojiImg with alt=emoji
|
||||||
|
const img = screen.getByAltText('❤️');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-018: WebSocket collab:message:created event adds message to list', async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
await waitFor(() => expect(addListener).toHaveBeenCalled());
|
||||||
|
const handler = (addListener as any).mock.calls[0][0];
|
||||||
|
await act(async () => {
|
||||||
|
handler({
|
||||||
|
type: 'collab:message:created',
|
||||||
|
tripId: 1,
|
||||||
|
message: {
|
||||||
|
id: 99, trip_id: 1, user_id: 2, username: 'alice',
|
||||||
|
text: 'WS message', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(await screen.findByText('WS message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-019: WebSocket collab:message:deleted event marks message as deleted', async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'To remove', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('To remove');
|
||||||
|
await waitFor(() => expect(addListener).toHaveBeenCalled());
|
||||||
|
const handler = (addListener as any).mock.calls[0][0];
|
||||||
|
await act(async () => {
|
||||||
|
handler({ type: 'collab:message:deleted', tripId: 1, messageId: 1 });
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('To remove')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-020: send button is disabled when input is empty', async () => {
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
// The send button is the ArrowUp button — it has disabled attr when text is empty
|
||||||
|
const sendButton = buttons.find(b => b.hasAttribute('disabled'));
|
||||||
|
expect(sendButton).toBeTruthy();
|
||||||
|
expect(sendButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-021: reply-to banner shows quoted author and text', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'Reply here', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null,
|
||||||
|
reply_text: 'Original message', reply_username: 'alice',
|
||||||
|
deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Reply here');
|
||||||
|
expect(screen.getByText(/Original message/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-022: own messages are displayed with blue bubble', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
|
||||||
|
text: 'My own message', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('My own message');
|
||||||
|
// Own messages don't show a username label above the bubble (only other users get it)
|
||||||
|
// The component renders {!own && isNewGroup && <span>{msg.username}</span>}
|
||||||
|
// so 'testuser' should NOT appear as a username label
|
||||||
|
const usernameLabels = screen.queryAllByText('testuser');
|
||||||
|
expect(usernameLabels.length).toBe(0);
|
||||||
|
// And own message bubble uses row-reverse flex direction
|
||||||
|
const messageEl = screen.getByText('My own message');
|
||||||
|
let parent = messageEl.parentElement;
|
||||||
|
let foundRowReverse = false;
|
||||||
|
while (parent) {
|
||||||
|
const styleAttr = parent.getAttribute('style');
|
||||||
|
if (styleAttr && styleAttr.includes('row-reverse')) {
|
||||||
|
foundRowReverse = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
expect(foundRowReverse).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-023: sending a message clears the input field', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/collab/messages', async () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
message: {
|
||||||
|
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
|
||||||
|
avatar_url: null, text: 'Sent message', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
const input = screen.getByPlaceholderText('Type a message...');
|
||||||
|
await user.type(input, 'Sent message');
|
||||||
|
expect((input as HTMLTextAreaElement).value).toBe('Sent message');
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((input as HTMLTextAreaElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-024: load earlier messages button appears when 100+ messages exist', async () => {
|
||||||
|
const messages = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: i + 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: `Message ${i + 1}`, created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}));
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({ messages, total: 100 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Message 1');
|
||||||
|
const loadMoreBtn = await screen.findByRole('button', { name: /load/i });
|
||||||
|
expect(loadMoreBtn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-025: clicking reply button on a message sets reply-to preview', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'Reply to me', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Reply to me');
|
||||||
|
// Hover action buttons are always in DOM but hidden via pointer-events: none
|
||||||
|
// Use fireEvent to bypass CSS pointer-events restrictions
|
||||||
|
const replyBtn = screen.getByTitle('Reply');
|
||||||
|
fireEvent.click(replyBtn);
|
||||||
|
// Reply preview banner renders <strong>{username}</strong> — unique to the banner
|
||||||
|
await waitFor(() => {
|
||||||
|
const aliceEls = screen.queryAllByText('alice');
|
||||||
|
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-026: clicking X in reply preview cancels reply', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'Cancel reply test', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Cancel reply test');
|
||||||
|
// Click reply button to show preview (bypassing pointer-events: none)
|
||||||
|
fireEvent.click(screen.getByTitle('Reply'));
|
||||||
|
// Wait for reply preview <strong> to appear
|
||||||
|
await waitFor(() => {
|
||||||
|
const aliceEls = screen.queryAllByText('alice');
|
||||||
|
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
|
||||||
|
});
|
||||||
|
// Find the X button inside the reply preview — the <strong> is inside a <span> inside the preview div
|
||||||
|
const strongEl = screen.getAllByText('alice').find(el => el.tagName === 'STRONG')!;
|
||||||
|
const previewDiv = strongEl.closest('div[style]');
|
||||||
|
const xBtn = previewDiv?.querySelector('button');
|
||||||
|
expect(xBtn).toBeTruthy();
|
||||||
|
fireEvent.click(xBtn!);
|
||||||
|
await waitFor(() => {
|
||||||
|
// After cancel, no <strong>alice</strong> in reply preview
|
||||||
|
const remaining = screen.queryAllByText('alice');
|
||||||
|
expect(remaining.every(el => el.tagName !== 'STRONG')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-027: clicking emoji button opens the emoji picker', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
// Smile button is the only non-disabled button when input is empty
|
||||||
|
const allButtons = screen.getAllByRole('button');
|
||||||
|
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
|
||||||
|
expect(smileBtn).toBeTruthy();
|
||||||
|
await user.click(smileBtn!);
|
||||||
|
// EmojiPicker renders category tabs
|
||||||
|
await screen.findByText('Smileys');
|
||||||
|
expect(screen.getByText('Reactions')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-028: selecting emoji from picker appends it to the input', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Start the conversation');
|
||||||
|
const allButtons = screen.getAllByRole('button');
|
||||||
|
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
|
||||||
|
await user.click(smileBtn!);
|
||||||
|
// Wait for picker to open
|
||||||
|
await screen.findByText('Smileys');
|
||||||
|
// Click the first emoji in the grid (😀 is the first in Smileys)
|
||||||
|
const emojiImg = screen.getAllByRole('img').find(img => img.getAttribute('alt') === '😀');
|
||||||
|
expect(emojiImg).toBeTruthy();
|
||||||
|
await user.click(emojiImg!.closest('button')!);
|
||||||
|
// Emoji should be appended to textarea
|
||||||
|
const textarea = screen.getByPlaceholderText('Type a message...');
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toContain('😀');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-029: right-clicking a message opens the reaction menu', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'Right click me', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Right click me');
|
||||||
|
const messageBubble = screen.getByText('Right click me').closest('div[style]');
|
||||||
|
fireEvent.contextMenu(messageBubble!);
|
||||||
|
// ReactionMenu renders quick reactions (❤️ is the first)
|
||||||
|
await waitFor(() => {
|
||||||
|
const reactionImgs = screen.getAllByRole('img').filter(img =>
|
||||||
|
['❤️', '😂', '👍'].includes(img.getAttribute('alt') || '')
|
||||||
|
);
|
||||||
|
expect(reactionImgs.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-030: clicking a reaction in the menu calls reactMessage API', async () => {
|
||||||
|
let reactCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'React to this', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.post('/api/trips/1/collab/messages/1/react', async () => {
|
||||||
|
reactCalled = true;
|
||||||
|
return HttpResponse.json({ reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }] });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('React to this');
|
||||||
|
// Open reaction context menu
|
||||||
|
const messageBubble = screen.getByText('React to this').closest('div[style]');
|
||||||
|
fireEvent.contextMenu(messageBubble!);
|
||||||
|
// Wait for menu and click first reaction (❤️)
|
||||||
|
const heartImg = await screen.findByAltText('❤️');
|
||||||
|
fireEvent.click(heartImg.closest('button')!);
|
||||||
|
await waitFor(() => expect(reactCalled).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-031: WebSocket collab:message:reacted event updates reactions', async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'Reacted message', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Reacted message');
|
||||||
|
await waitFor(() => expect(addListener).toHaveBeenCalled());
|
||||||
|
const handler = (addListener as any).mock.calls[0][0];
|
||||||
|
await act(async () => {
|
||||||
|
handler({
|
||||||
|
type: 'collab:message:reacted',
|
||||||
|
tripId: 1,
|
||||||
|
messageId: 1,
|
||||||
|
reactions: [{ emoji: '🔥', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await screen.findByAltText('🔥');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-032: clicking "Load older messages" loads paginated results', async () => {
|
||||||
|
const initialMessages = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: i + 100, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: `New ${i + 100}`, created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}));
|
||||||
|
let callCount = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return HttpResponse.json({ messages: initialMessages, total: 120 });
|
||||||
|
}
|
||||||
|
return HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'Older message', created_at: '2020-01-01T10:00:00.000Z',
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 120,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('New 100');
|
||||||
|
const loadMoreBtn = screen.getByRole('button', { name: /load/i });
|
||||||
|
await user.click(loadMoreBtn);
|
||||||
|
await screen.findByText('Older message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-033: clicking delete on own message marks it as deleted', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
|
||||||
|
text: 'Delete me', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.delete('/api/trips/1/collab/messages/1', () =>
|
||||||
|
HttpResponse.json({ success: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Delete me');
|
||||||
|
// Delete button is in a hover-actions div with pointer-events: none — use fireEvent
|
||||||
|
const deleteBtn = screen.getByTitle('Delete');
|
||||||
|
fireEvent.click(deleteBtn);
|
||||||
|
// handleDelete uses a 400ms setTimeout before calling the API
|
||||||
|
await waitFor(
|
||||||
|
() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(),
|
||||||
|
{ timeout: 1500 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-034: single-emoji message renders as big emoji', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: '👍', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('👍');
|
||||||
|
// Big emoji renders in a div with fontSize: 40px — include emojiEl itself in search
|
||||||
|
const emojiEl = screen.getByText('👍');
|
||||||
|
let el: HTMLElement | null = emojiEl as HTMLElement;
|
||||||
|
let foundBigEmoji = false;
|
||||||
|
while (el) {
|
||||||
|
const styleAttr = el.getAttribute('style');
|
||||||
|
if (styleAttr && styleAttr.includes('font-size: 40px')) {
|
||||||
|
foundBigEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
expect(foundBigEmoji).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-035: 24h time format renders timestamp without AM/PM', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } as any });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: 'Time format test', created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText('Time format test');
|
||||||
|
// 24h format: timestamp like "HH:MM" — no AM/PM suffix
|
||||||
|
expect(screen.queryByText(/AM|PM/)).not.toBeInTheDocument();
|
||||||
|
// There should be a timestamp element matching HH:MM
|
||||||
|
const timestamp = screen.getByText((text) => /^\d{1,2}:\d{2}$/.test(text));
|
||||||
|
expect(timestamp).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-CHAT-036: message with URL shows link preview when API returns data', async () => {
|
||||||
|
const uniqueUrl = 'https://preview-test-unique-url-9999.example.com/page';
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/messages', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
messages: [{
|
||||||
|
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
|
||||||
|
text: `Check this out ${uniqueUrl}`,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
reactions: [], reply_to: null, deleted: false, edited: false,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.get('/api/trips/1/collab/link-preview', () =>
|
||||||
|
HttpResponse.json({ title: 'Preview Title', description: 'Preview Desc', image: null, site_name: 'Example' })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(<CollabChat {...defaultProps} />);
|
||||||
|
await screen.findByText(/Check this out/);
|
||||||
|
await waitFor(
|
||||||
|
() => expect(screen.getByText('Preview Title')).toBeInTheDocument(),
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'
|
|||||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
@@ -353,6 +355,9 @@ interface CollabChatProps {
|
|||||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
|
|
||||||
const [messages, setMessages] = useState([])
|
const [messages, setMessages] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -365,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
const [showEmoji, setShowEmoji] = useState(false)
|
const [showEmoji, setShowEmoji] = useState(false)
|
||||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||||
|
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { deleteTimersRef.current.forEach(clearTimeout) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
const messagesRef = useRef(messages)
|
const messagesRef = useRef(messages)
|
||||||
@@ -478,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||||
})
|
})
|
||||||
setTimeout(async () => {
|
const t = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await collabApi.deleteMessage(tripId, msgId)
|
await collabApi.deleteMessage(tripId, msgId)
|
||||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||||
} catch {}
|
} catch {}
|
||||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||||
}, 400)
|
}, 400)
|
||||||
|
deleteTimersRef.current.push(t)
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
const handleReact = useCallback(async (msgId, emoji) => {
|
const handleReact = useCallback(async (msgId, emoji) => {
|
||||||
@@ -636,11 +647,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
onMouseEnter={() => setHoveredId(msg.id)}
|
onMouseEnter={() => setHoveredId(msg.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||||
onTouchEnd={e => {
|
onTouchEnd={e => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||||
if (now - lastTap < 300) {
|
if (now - lastTap < 300 && canEdit) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const touch = e.changedTouches?.[0]
|
const touch = e.changedTouches?.[0]
|
||||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||||
@@ -692,7 +703,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
transition: 'opacity .1s',
|
transition: 'opacity .1s',
|
||||||
...(own ? { left: -6 } : { right: -6 }),
|
...(own ? { left: -6 } : { right: -6 }),
|
||||||
}}>
|
}}>
|
||||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||||
@@ -703,8 +714,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
>
|
>
|
||||||
<Reply size={11} />
|
<Reply size={11} />
|
||||||
</button>
|
</button>
|
||||||
{own && (
|
{own && canEdit && (
|
||||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||||
@@ -735,7 +746,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
{msg.reactions.map(r => {
|
{msg.reactions.map(r => {
|
||||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||||
return (
|
return (
|
||||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -757,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Composer */}
|
{/* Composer */}
|
||||||
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}>
|
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
|
||||||
{/* Reply preview */}
|
{/* Reply preview */}
|
||||||
{replyTo && (
|
{replyTo && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -780,23 +791,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||||
{/* Emoji button */}
|
{/* Emoji button */}
|
||||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
{canEdit && (
|
||||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||||
<Smile size={20} />
|
}}>
|
||||||
</button>
|
<Smile size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
disabled={!canEdit}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||||
maxHeight: 100, overflowY: 'hidden',
|
maxHeight: 100, overflowY: 'hidden',
|
||||||
|
opacity: canEdit ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
placeholder={t('collab.chat.placeholder')}
|
placeholder={t('collab.chat.placeholder')}
|
||||||
value={text}
|
value={text}
|
||||||
@@ -805,15 +820,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Send */}
|
{/* Send */}
|
||||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
{canEdit && (
|
||||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
transition: 'background 0.15s',
|
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||||
}}>
|
transition: 'background 0.15s',
|
||||||
<ArrowUp size={18} strokeWidth={2.5} />
|
}}>
|
||||||
</button>
|
<ArrowUp size={18} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,13 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
|||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
|
import remarkBreaks from 'remark-breaks'
|
||||||
|
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
|
||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
import { openFile } from '../../utils/fileDownload'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
@@ -94,22 +99,34 @@ interface FilePreviewPortalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||||
|
const [authUrl, setAuthUrl] = useState('')
|
||||||
|
const rawUrl = file?.url || ''
|
||||||
|
useEffect(() => {
|
||||||
|
setAuthUrl('')
|
||||||
|
if (!rawUrl) return
|
||||||
|
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||||
|
}, [rawUrl])
|
||||||
|
|
||||||
if (!file) return null
|
if (!file) return null
|
||||||
const url = file.url || `/uploads/${file.filename}`
|
|
||||||
const isImage = file.mime_type?.startsWith('image/')
|
const isImage = file.mime_type?.startsWith('image/')
|
||||||
const isPdf = file.mime_type === 'application/pdf'
|
const isPdf = file.mime_type === 'application/pdf'
|
||||||
const isTxt = file.mime_type?.startsWith('text/')
|
const isTxt = file.mime_type?.startsWith('text/')
|
||||||
|
|
||||||
|
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
/* Image lightbox — floating controls */
|
/* Image lightbox — floating controls */
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||||
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
{authUrl
|
||||||
|
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||||
|
: <Loader2 size={32} className="animate-spin" style={{ color: 'rgba(255,255,255,0.5)' }} />
|
||||||
|
}
|
||||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
|
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,19 +137,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
|
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(isPdf || isTxt) ? (
|
{(isPdf || isTxt) ? (
|
||||||
<object data={`${url}#view=FitH`} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
|
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
|
||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
|
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,6 +159,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||||
|
const [authSrc, setAuthSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||||
|
}, [src])
|
||||||
|
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
const NOTE_COLORS = [
|
const NOTE_COLORS = [
|
||||||
{ value: '#6366f1', label: 'Indigo' },
|
{ value: '#6366f1', label: 'Indigo' },
|
||||||
{ value: '#ef4444', label: 'Red' },
|
{ value: '#ef4444', label: 'Red' },
|
||||||
@@ -216,7 +241,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
|||||||
interface NoteFormModalProps {
|
interface NoteFormModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
||||||
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
|
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||||
existingCategories: string[]
|
existingCategories: string[]
|
||||||
categoryColors: Record<string, string>
|
categoryColors: Record<string, string>
|
||||||
getCategoryColor: (category: string) => string
|
getCategoryColor: (category: string) => string
|
||||||
@@ -226,6 +251,9 @@ interface NoteFormModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
||||||
|
const can = useCanDo()
|
||||||
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
|
const canUploadFiles = can('file_upload', tripObj)
|
||||||
const isEdit = !!note
|
const isEdit = !!note
|
||||||
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
||||||
|
|
||||||
@@ -284,7 +312,6 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
padding: 16,
|
padding: 16,
|
||||||
fontFamily: FONT,
|
fontFamily: FONT,
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
style={{
|
style={{
|
||||||
@@ -298,6 +325,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
}}
|
}}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onPaste={e => {
|
onPaste={e => {
|
||||||
|
if (!canUploadFiles) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -450,18 +478,18 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File attachments */}
|
{/* File attachments */}
|
||||||
<div>
|
{canUploadFiles && <div>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||||
{t('collab.notes.attachFiles')}
|
{t('collab.notes.attachFiles')}
|
||||||
</div>
|
</div>
|
||||||
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
|
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{/* Existing attachments (edit mode) */}
|
{/* Existing attachments (edit mode) */}
|
||||||
{existingAttachments.map(a => {
|
{existingAttachments.map(a => {
|
||||||
const isImage = a.mime_type?.startsWith('image/')
|
const isImage = a.mime_type?.startsWith('image/')
|
||||||
return (
|
return (
|
||||||
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
{isImage && <img src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||||
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
|
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
|
||||||
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
|
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
@@ -478,12 +506,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<label htmlFor="note-file-input"
|
<button type="button" onClick={() => fileRef.current?.click()}
|
||||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||||
</label>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
@@ -689,6 +717,7 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
|
|||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
note: CollabNote
|
note: CollabNote
|
||||||
currentUser: User
|
currentUser: User
|
||||||
|
canEdit: boolean
|
||||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||||
onDelete: (noteId: number) => Promise<void>
|
onDelete: (noteId: number) => Promise<void>
|
||||||
onEdit: (note: CollabNote) => void
|
onEdit: (note: CollabNote) => void
|
||||||
@@ -699,7 +728,7 @@ interface NoteCardProps {
|
|||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||||
@@ -760,24 +789,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
|||||||
<Maximize2 size={10} />
|
<Maximize2 size={10} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={10} />
|
<Pencil size={10} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={handleDelete} title={t('collab.notes.delete')}
|
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Trash2 size={10} />
|
<Trash2 size={10} />
|
||||||
</button>
|
</button>}
|
||||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||||
{/* Author avatar */}
|
{/* Author avatar */}
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||||
@@ -815,7 +844,7 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
|||||||
maxHeight: '4.5em', overflow: 'hidden',
|
maxHeight: '4.5em', overflow: 'hidden',
|
||||||
wordBreak: 'break-word', fontFamily: FONT,
|
wordBreak: 'break-word', fontFamily: FONT,
|
||||||
}}>
|
}}>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -838,7 +867,7 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
|||||||
const isImage = a.mime_type?.startsWith('image/')
|
const isImage = a.mime_type?.startsWith('image/')
|
||||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||||
return isImage ? (
|
return isImage ? (
|
||||||
<img key={a.id} src={a.url} alt={a.original_name}
|
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||||
onClick={() => onPreviewFile?.(a)}
|
onClick={() => onPreviewFile?.(a)}
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||||
@@ -879,6 +908,9 @@ interface CollabNotesProps {
|
|||||||
|
|
||||||
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
const [notes, setNotes] = useState([])
|
const [notes, setNotes] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showNewModal, setShowNewModal] = useState(false)
|
const [showNewModal, setShowNewModal] = useState(false)
|
||||||
@@ -964,7 +996,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
for (const file of pendingFiles) {
|
for (const file of pendingFiles) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {}
|
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
|
||||||
}
|
}
|
||||||
// Reload note with attachments
|
// Reload note with attachments
|
||||||
const fresh = await collabApi.getNotes(tripId)
|
const fresh = await collabApi.getNotes(tripId)
|
||||||
@@ -1124,17 +1156,17 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
{t('collab.notes.title')}
|
{t('collab.notes.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Settings size={14} />
|
<Settings size={14} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => setShowNewModal(true)}
|
{canEdit && <button onClick={() => setShowNewModal(true)}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
{t('collab.notes.new')}
|
{t('collab.notes.new')}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1252,6 +1284,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
key={note.id}
|
key={note.id}
|
||||||
note={note}
|
note={note}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
canEdit={canEdit}
|
||||||
onUpdate={handleUpdateNote}
|
onUpdate={handleUpdateNote}
|
||||||
onDelete={handleDeleteNote}
|
onDelete={handleDeleteNote}
|
||||||
onEdit={setEditingNote}
|
onEdit={setEditingNote}
|
||||||
@@ -1303,12 +1336,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||||
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => setViewingNote(null)}
|
<button onClick={() => setViewingNote(null)}
|
||||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
@@ -1318,7 +1351,42 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
|
||||||
|
{(viewingNote.attachments || []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{(viewingNote.attachments || []).map(a => {
|
||||||
|
const isImage = a.mime_type?.startsWith('image/')
|
||||||
|
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||||
|
return (
|
||||||
|
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
|
||||||
|
{isImage ? (
|
||||||
|
<AuthedImg src={a.url} alt={a.original_name}
|
||||||
|
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||||
|
onClick={() => setPreviewFile(a)}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||||
|
) : (
|
||||||
|
<div title={a.original_name} onClick={() => setPreviewFile(a)}
|
||||||
|
style={{
|
||||||
|
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
|
||||||
|
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||||
|
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -1327,6 +1395,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
|
|
||||||
{showNewModal && (
|
{showNewModal && (
|
||||||
<NoteFormModal
|
<NoteFormModal
|
||||||
|
note={null}
|
||||||
|
tripId={tripId}
|
||||||
onClose={() => setShowNewModal(false)}
|
onClose={() => setShowNewModal(false)}
|
||||||
onSubmit={handleCreateNote}
|
onSubmit={handleCreateNote}
|
||||||
existingCategories={categories}
|
existingCategories={categories}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||||
|
import { buildUser } from '../../../tests/helpers/factories'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
|
||||||
|
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
|
||||||
|
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
|
||||||
|
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
|
||||||
|
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
|
||||||
|
vi.mock('../../api/websocket', () => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
getSocketId: vi.fn(() => null),
|
||||||
|
setRefetchCallback: vi.fn(),
|
||||||
|
setPreReconnectHook: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import CollabPanel from './CollabPanel'
|
||||||
|
|
||||||
|
let originalInnerWidth: number
|
||||||
|
|
||||||
|
function setViewport(width: number) {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollabPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
originalInnerWidth = window.innerWidth
|
||||||
|
resetAllStores()
|
||||||
|
seedStore(useAuthStore, { user: buildUser() })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-001
|
||||||
|
it('desktop layout renders all four panels', () => {
|
||||||
|
setViewport(1280)
|
||||||
|
render(<CollabPanel tripId={1} />)
|
||||||
|
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-002
|
||||||
|
it('mobile layout renders tab bar, not all panels at once', () => {
|
||||||
|
setViewport(375)
|
||||||
|
render(<CollabPanel tripId={1} />)
|
||||||
|
// Tab buttons exist
|
||||||
|
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
|
||||||
|
// Only chat visible by default
|
||||||
|
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-003
|
||||||
|
it('mobile: clicking Notes tab switches to CollabNotes', () => {
|
||||||
|
setViewport(375)
|
||||||
|
render(<CollabPanel tripId={1} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /notes/i }))
|
||||||
|
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-004
|
||||||
|
it('mobile: clicking Polls tab switches to CollabPolls', () => {
|
||||||
|
setViewport(375)
|
||||||
|
render(<CollabPanel tripId={1} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /polls/i }))
|
||||||
|
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-005
|
||||||
|
it('mobile: clicking What\'s Next tab shows WhatsNextWidget', () => {
|
||||||
|
setViewport(375)
|
||||||
|
render(<CollabPanel tripId={1} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /what.?s next/i }))
|
||||||
|
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-006
|
||||||
|
it('mobile: active tab button has accent background style', () => {
|
||||||
|
setViewport(375)
|
||||||
|
render(<CollabPanel tripId={1} />)
|
||||||
|
const chatButton = screen.getByRole('button', { name: /chat/i })
|
||||||
|
expect(chatButton.style.background).toBe('var(--accent)')
|
||||||
|
const notesButton = screen.getByRole('button', { name: /notes/i })
|
||||||
|
expect(notesButton.style.background).toBe('transparent')
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-007
|
||||||
|
it('mobile: default active tab is Chat', () => {
|
||||||
|
setViewport(375)
|
||||||
|
render(<CollabPanel tripId={1} />)
|
||||||
|
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-008
|
||||||
|
it('tripMembers prop is forwarded to WhatsNextWidget', () => {
|
||||||
|
setViewport(1280)
|
||||||
|
render(<CollabPanel tripId={1} tripMembers={[{ id: 5, username: 'alice', avatar_url: null }]} />)
|
||||||
|
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-009
|
||||||
|
it('tripId prop is forwarded to child components', () => {
|
||||||
|
setViewport(1280)
|
||||||
|
render(<CollabPanel tripId={1} />)
|
||||||
|
// All children render without errors, confirming props were forwarded
|
||||||
|
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-COMP-COLLABPANEL-010
|
||||||
|
it('resize from desktop to mobile hides side-by-side layout', () => {
|
||||||
|
setViewport(1280)
|
||||||
|
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||||
|
// All four panels visible on desktop
|
||||||
|
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Switch to mobile
|
||||||
|
setViewport(375)
|
||||||
|
rerender(<CollabPanel tripId={1} />)
|
||||||
|
|
||||||
|
// Tab bar appears, only chat visible
|
||||||
|
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||||
@@ -29,54 +29,142 @@ interface TripMember {
|
|||||||
avatar_url?: string | null
|
avatar_url?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CollabFeatures {
|
||||||
|
chat: boolean
|
||||||
|
notes: boolean
|
||||||
|
polls: boolean
|
||||||
|
whatsnext: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface CollabPanelProps {
|
interface CollabPanelProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
tripMembers?: TripMember[]
|
tripMembers?: TripMember[]
|
||||||
|
collabFeatures?: CollabFeatures
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
|
const ALL_TABS = [
|
||||||
|
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||||
|
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||||
|
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||||
|
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [mobileTab, setMobileTab] = useState('chat')
|
|
||||||
const isDesktop = useIsDesktop()
|
const isDesktop = useIsDesktop()
|
||||||
|
|
||||||
const tabs = [
|
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||||
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
|
|
||||||
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
|
const tabs = useMemo(() =>
|
||||||
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
|
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||||
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
|
...tab,
|
||||||
]
|
label: t(tab.labelKey) || tab.fallback,
|
||||||
|
})),
|
||||||
|
[features, t])
|
||||||
|
|
||||||
|
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat')
|
||||||
|
|
||||||
|
// If active tab gets disabled, switch to first available
|
||||||
|
useEffect(() => {
|
||||||
|
if (tabs.length > 0 && !tabs.some(t => t.id === mobileTab)) {
|
||||||
|
setMobileTab(tabs[0].id)
|
||||||
|
}
|
||||||
|
}, [tabs, mobileTab])
|
||||||
|
|
||||||
|
const chatOn = features.chat
|
||||||
|
const rightPanels = [
|
||||||
|
features.notes && 'notes',
|
||||||
|
features.polls && 'polls',
|
||||||
|
features.whatsnext && 'whatsnext',
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
if (tabs.length === 0) return null
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
|
// Chat always 380px fixed when on. Right panels share remaining space.
|
||||||
|
// If chat off, all panels share full width equally.
|
||||||
|
if (chatOn && rightPanels.length === 0) {
|
||||||
|
// Only chat
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<div style={{ ...card, flex: 1 }}>
|
||||||
|
<CollabChat tripId={tripId} currentUser={user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatOn) {
|
||||||
|
// Chat left (380px) + right panels
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||||
|
<CollabChat tripId={tripId} currentUser={user} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
{rightPanels.length === 1 && (
|
||||||
|
<div style={{ ...card, flex: 1 }}>
|
||||||
|
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
|
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
|
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||||
|
<div key={p} style={{ ...card, flex: 1 }}>
|
||||||
|
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
|
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
|
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rightPanels.length === 3 && (
|
||||||
|
<>
|
||||||
|
<div style={{ ...card, flex: 1 }}>
|
||||||
|
<CollabNotes tripId={tripId} currentUser={user} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<div style={{ ...card, flex: 1 }}>
|
||||||
|
<CollabPolls tripId={tripId} currentUser={user} />
|
||||||
|
</div>
|
||||||
|
<div style={{ ...card, flex: 1 }}>
|
||||||
|
<WhatsNextWidget tripMembers={tripMembers} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat off — remaining panels share full width
|
||||||
|
const panels = rightPanels
|
||||||
|
if (panels.length === 1) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<div style={{ ...card, flex: 1 }}>
|
||||||
|
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
|
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
|
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||||
{/* Chat — left, fixed width */}
|
{panels.map(p => (
|
||||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
<div key={p} style={{ ...card, flex: 1 }}>
|
||||||
<CollabChat tripId={tripId} currentUser={user} />
|
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
</div>
|
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
|
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
{/* Right column: Notes top, Polls + What's Next bottom */}
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
|
||||||
{/* Notes — top */}
|
|
||||||
<div style={{ ...card, flex: 1 }}>
|
|
||||||
<CollabNotes tripId={tripId} currentUser={user} />
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
{/* Polls + What's Next — bottom row */}
|
|
||||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
|
||||||
<div style={{ ...card, flex: 1 }}>
|
|
||||||
<CollabPolls tripId={tripId} currentUser={user} />
|
|
||||||
</div>
|
|
||||||
<div style={{ ...card, flex: 1 }}>
|
|
||||||
<WhatsNextWidget tripMembers={tripMembers} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: tab bar + single panel
|
// Mobile: tab bar + single panel (only enabled tabs)
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -84,7 +172,6 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
|
|||||||
background: 'var(--bg-card)', flexShrink: 0,
|
background: 'var(--bg-card)', flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{tabs.map(tab => {
|
{tabs.map(tab => {
|
||||||
const Icon = tab.icon
|
|
||||||
const active = mobileTab === tab.id
|
const active = mobileTab === tab.id
|
||||||
return (
|
return (
|
||||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||||
@@ -102,10 +189,10 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
|
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||||
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||||
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||||
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
|
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// FE-COMP-POLLS-001 to FE-COMP-POLLS-015
|
||||||
|
|
||||||
|
vi.mock('../../api/websocket', () => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
getSocketId: vi.fn(() => null),
|
||||||
|
setRefetchCallback: vi.fn(),
|
||||||
|
setPreReconnectHook: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||||
|
import CollabPolls from './CollabPolls';
|
||||||
|
import { addListener } from '../../api/websocket';
|
||||||
|
|
||||||
|
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||||
|
|
||||||
|
const buildPoll = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
question: 'Best destination?',
|
||||||
|
options: [
|
||||||
|
{ id: 1, text: 'Paris', label: 'Paris', voters: [] },
|
||||||
|
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
|
||||||
|
],
|
||||||
|
multi_choice: false,
|
||||||
|
is_closed: false,
|
||||||
|
deadline: null,
|
||||||
|
created_by: 1,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps = { tripId: 1, currentUser };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ polls: [] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CollabPolls', () => {
|
||||||
|
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-002: shows loading spinner initially', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
return HttpResponse.json({ polls: [] });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
// The spinner is a div with animation style
|
||||||
|
expect(
|
||||||
|
document.querySelector('[style*="animation"]'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-003: renders poll question from API', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ polls: [buildPoll()] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText('Best destination?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-004: renders poll options', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ polls: [buildPoll()] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText('Paris');
|
||||||
|
expect(screen.getByText('Rome')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-005: New Poll button is visible when user can edit', async () => {
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
// Wait for loading to finish
|
||||||
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /new/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-006: clicking New Poll button opens the create modal', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||||
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||||
|
// Modal has a question placeholder input
|
||||||
|
await screen.findByPlaceholderText(/what should we do/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-007: create modal requires question and at least 2 options to enable submit', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||||
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||||
|
|
||||||
|
// Find submit button - it's the form submit with the create label
|
||||||
|
const submitBtn = screen.getByRole('button', { name: /create|collab\.polls\.create/i });
|
||||||
|
expect(submitBtn).toBeDisabled();
|
||||||
|
|
||||||
|
// Fill in question
|
||||||
|
const questionInput = screen.getByPlaceholderText(/what should we do/i);
|
||||||
|
await user.type(questionInput, 'Where to go?');
|
||||||
|
|
||||||
|
// Still disabled — no options filled
|
||||||
|
expect(submitBtn).toBeDisabled();
|
||||||
|
|
||||||
|
// Fill in 2 options
|
||||||
|
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||||
|
await user.type(optionInputs[0], 'Beach');
|
||||||
|
await user.type(optionInputs[1], 'Mountain');
|
||||||
|
|
||||||
|
expect(submitBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-008: creating a poll calls POST API and adds it to the list', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||||
|
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Where to eat?');
|
||||||
|
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||||
|
await user.type(optionInputs[0], 'Italian');
|
||||||
|
await user.type(optionInputs[1], 'Japanese');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||||
|
await screen.findByText('Where to eat?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
|
||||||
|
let voteCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ polls: [buildPoll()] }),
|
||||||
|
),
|
||||||
|
http.post('/api/trips/1/collab/polls/1/vote', () => {
|
||||||
|
voteCalled = true;
|
||||||
|
return HttpResponse.json({
|
||||||
|
poll: buildPoll({
|
||||||
|
options: [
|
||||||
|
{ id: 1, text: 'Paris', label: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
|
||||||
|
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText('Paris');
|
||||||
|
await user.click(screen.getByText('Paris'));
|
||||||
|
await waitFor(() => expect(voteCalled).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-010: closed poll shows "Closed" badge', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText(/closed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText('Paris');
|
||||||
|
const parisBtn = screen.getByText('Paris').closest('button');
|
||||||
|
expect(parisBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-012: delete button calls DELETE API and removes poll', async () => {
|
||||||
|
let deleteCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ polls: [buildPoll({ id: 5 })] }),
|
||||||
|
),
|
||||||
|
http.delete('/api/trips/1/collab/polls/5', () => {
|
||||||
|
deleteCalled = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText('Best destination?');
|
||||||
|
|
||||||
|
// Delete button has a title with "delete"
|
||||||
|
const deleteBtn = screen.getByTitle(/delete/i);
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-013: WebSocket collab:poll:created event adds poll', async () => {
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||||
|
|
||||||
|
// Get the WS listener that was registered
|
||||||
|
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
|
listener({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
|
||||||
|
|
||||||
|
await screen.findByText('Live poll?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/collab/polls', () =>
|
||||||
|
HttpResponse.json({ polls: [buildPoll({ id: 3 })] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText('Best destination?');
|
||||||
|
|
||||||
|
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
|
listener({ type: 'collab:poll:deleted', pollId: 3 });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-POLLS-015: adding a third option in create modal', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CollabPolls {...defaultProps} />);
|
||||||
|
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||||
|
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||||
|
|
||||||
|
// Initially 2 option inputs
|
||||||
|
let optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||||
|
expect(optionInputs).toHaveLength(2);
|
||||||
|
|
||||||
|
// Click "Add option"
|
||||||
|
await user.click(screen.getByText(/add option/i));
|
||||||
|
|
||||||
|
optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||||
|
expect(optionInputs).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
|||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
|
|
||||||
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
|||||||
interface PollCardProps {
|
interface PollCardProps {
|
||||||
poll: Poll
|
poll: Poll
|
||||||
currentUser: User
|
currentUser: User
|
||||||
|
canEdit: boolean
|
||||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||||
onClose: (pollId: number) => Promise<void>
|
onClose: (pollId: number) => Promise<void>
|
||||||
onDelete: (pollId: number) => Promise<void>
|
onDelete: (pollId: number) => Promise<void>
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
|
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||||
const total = totalVotes(poll)
|
const total = totalVotes(poll)
|
||||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||||
const remaining = timeRemaining(poll.deadline)
|
const remaining = timeRemaining(poll.deadline)
|
||||||
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
{canEdit && (
|
||||||
{!isClosed && (
|
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
{!isClosed && (
|
||||||
|
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||||
|
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Lock size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Lock size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
)}
|
||||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options */}
|
{/* Options */}
|
||||||
@@ -337,6 +342,9 @@ interface CollabPollsProps {
|
|||||||
|
|
||||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
const [polls, setPolls] = useState([])
|
const [polls, setPolls] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
<BarChart3 size={14} color="var(--text-faint)" />
|
<BarChart3 size={14} color="var(--text-faint)" />
|
||||||
{t('collab.polls.title')}
|
{t('collab.polls.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setShowForm(true)} style={{
|
{canEdit && (
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
<button onClick={() => setShowForm(true)} style={{
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||||
}}>
|
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||||
<Plus size={12} /> {t('collab.polls.new')}
|
}}>
|
||||||
</button>
|
<Plus size={12} /> {t('collab.polls.new')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{activePolls.length > 0 && activePolls.map(poll => (
|
{activePolls.length > 0 && activePolls.map(poll => (
|
||||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||||
))}
|
))}
|
||||||
{closedPolls.length > 0 && (
|
{closedPolls.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{closedPolls.map(poll => (
|
{closedPolls.map(poll => (
|
||||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { render, screen } from '../../../tests/helpers/render'
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import WhatsNextWidget from './WhatsNextWidget'
|
||||||
|
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
// Dynamic date helpers
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
|
function getFutureDate(daysAhead: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + daysAhead)
|
||||||
|
return d.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPastDate(daysBack: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - daysBack)
|
||||||
|
return d.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const tomorrow = getFutureDate(1)
|
||||||
|
const yesterday = getPastDate(1)
|
||||||
|
|
||||||
|
function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}, participants: unknown[] = []) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
day_id: 1,
|
||||||
|
place_id: id,
|
||||||
|
order_index: 0,
|
||||||
|
notes: null,
|
||||||
|
place: {
|
||||||
|
id,
|
||||||
|
trip_id: 1,
|
||||||
|
name: `Place ${id}`,
|
||||||
|
description: null,
|
||||||
|
lat: 0,
|
||||||
|
lng: 0,
|
||||||
|
address: null,
|
||||||
|
category_id: null,
|
||||||
|
icon: null,
|
||||||
|
price: null,
|
||||||
|
image_url: null,
|
||||||
|
google_place_id: null,
|
||||||
|
osm_id: null,
|
||||||
|
route_geometry: null,
|
||||||
|
place_time: null,
|
||||||
|
end_time: null,
|
||||||
|
created_at: '2025-01-01T00:00:00.000Z',
|
||||||
|
...placeOverrides,
|
||||||
|
},
|
||||||
|
participants,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WhatsNextWidget', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-001: renders empty state when no days exist', () => {
|
||||||
|
seedStore(useTripStore, { days: [], assignments: {} })
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
// Translation resolves to "No upcoming activities"
|
||||||
|
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Place 1')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-001b: empty state element is rendered', () => {
|
||||||
|
seedStore(useTripStore, { days: [], assignments: {} })
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
// collab.whatsNext.empty key is rendered as text in test env
|
||||||
|
const allText = document.body.textContent || ''
|
||||||
|
// No assignment time/name visible — just the header and empty hint
|
||||||
|
expect(allText).not.toContain('14:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(10, { place_time: '08:00' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.queryByText('08:00')).toBeNull()
|
||||||
|
expect(screen.queryByText('Place 10')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(21, { name: 'Museum' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
// The label text comes from t('collab.whatsNext.tomorrow') which falls back to 'Tomorrow'
|
||||||
|
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.getByText(/today/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
|
||||||
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.getByText('14:30')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
|
||||||
|
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.getByText('2:30 PM')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.getByText('TBD')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-010: caps list at 8 items', () => {
|
||||||
|
const days = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
trip_id: 1,
|
||||||
|
date: getFutureDate(i + 1),
|
||||||
|
title: null,
|
||||||
|
order: i,
|
||||||
|
assignments: [],
|
||||||
|
notes_items: [],
|
||||||
|
notes: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const assignments: Record<string, unknown[]> = {}
|
||||||
|
let placeId = 100
|
||||||
|
for (const day of days) {
|
||||||
|
assignments[String(day.id)] = [
|
||||||
|
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '10:00' }),
|
||||||
|
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '11:00' }),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
seedStore(useTripStore, { days, assignments })
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
|
||||||
|
// 10 items seeded, only 8 should appear — count "TBD" or time occurrences
|
||||||
|
const timeElements = screen.getAllByText('10:00')
|
||||||
|
// At most 4 days * 1 morning slot = up to 4 "10:00" entries, but capped at 8 total items
|
||||||
|
// We verify total rendered items is at most 8 by counting both time slots
|
||||||
|
const allTimes = screen.getAllByText(/10:00|11:00/)
|
||||||
|
expect(allTimes.length).toBeLessThanOrEqual(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(41, { name: 'Park' }, [])],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />)
|
||||||
|
expect(screen.getByText('bob')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
expect(screen.getByText('19:00')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('21:30')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [
|
||||||
|
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
|
||||||
|
makeAssignment(61, { name: 'Lunch', place_time: '12:00' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
const tomorrowHeaders = screen.getAllByText(/tomorrow/i)
|
||||||
|
// Only one day header for tomorrow
|
||||||
|
expect(tomorrowHeaders).toHaveLength(1)
|
||||||
|
expect(screen.getByText('Breakfast')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Lunch')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
|
||||||
|
// If it's not midnight, a past-time event today should not appear
|
||||||
|
const now = new Date()
|
||||||
|
if (now.getHours() > 0) {
|
||||||
|
const pastTime = '00:01' // Very early — will be past for most of the day
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||||
|
assignments: {
|
||||||
|
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<WhatsNextWidget />)
|
||||||
|
// If current time > 00:01, the item should not appear
|
||||||
|
if (now.getHours() > 0 || now.getMinutes() > 1) {
|
||||||
|
expect(screen.queryByText('Early Bird')).toBeNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,14 +16,15 @@ function formatTime(timeStr, is12h) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDayLabel(date, t, locale) {
|
function formatDayLabel(date, t, locale) {
|
||||||
const d = new Date(date + 'T00:00:00')
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
|
const nowDate = now.toISOString().split('T')[0]
|
||||||
|
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
||||||
|
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
|
||||||
|
|
||||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
|
||||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||||
|
|
||||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TripMember {
|
interface TripMember {
|
||||||
|
|||||||
@@ -4,17 +4,23 @@ import { useTranslation } from '../../i18n'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
|
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||||
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
|
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||||
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
|
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||||
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
|
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||||
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
|
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||||
|
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||||
|
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||||
|
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||||
|
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||||
|
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||||
|
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||||
]
|
]
|
||||||
|
|
||||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||||
|
|
||||||
export default function CurrencyWidget() {
|
export default function CurrencyWidget() {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||||
const [amount, setAmount] = useState('100')
|
const [amount, setAmount] = useState('100')
|
||||||
@@ -40,7 +46,7 @@ export default function CurrencyWidget() {
|
|||||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||||
const formatNumber = (num) => {
|
const formatNumber = (num) => {
|
||||||
if (!num || num === '—') return '—'
|
if (!num || num === '—') return '—'
|
||||||
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
}
|
}
|
||||||
const result = rawResult
|
const result = rawResult
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen } from '../../../tests/helpers/render'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import TimezoneWidget from './TimezoneWidget'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TimezoneWidget', () => {
|
||||||
|
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
expect(document.body).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
|
||||||
|
expect(timeElements.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
|
||||||
|
localStorage.clear()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
|
||||||
|
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('New York')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
const allButtons = screen.getAllByRole('button')
|
||||||
|
await user.click(allButtons[0])
|
||||||
|
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
// Open add panel
|
||||||
|
const allButtons = screen.getAllByRole('button')
|
||||||
|
await user.click(allButtons[0])
|
||||||
|
// Find and click Berlin in the popular zones list
|
||||||
|
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||||
|
await user.click(berlinButton)
|
||||||
|
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||||
|
// Panel should be closed
|
||||||
|
expect(screen.queryByText('Custom Timezone')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
// Open add panel
|
||||||
|
const allButtons = screen.getAllByRole('button')
|
||||||
|
await user.click(allButtons[0])
|
||||||
|
// Type label and timezone
|
||||||
|
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||||
|
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||||
|
await user.type(labelInput, 'My City')
|
||||||
|
await user.type(tzInput, 'Europe/Paris')
|
||||||
|
// Click Add
|
||||||
|
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||||
|
await user.click(addButton)
|
||||||
|
expect(await screen.findByText('My City')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
const allButtons = screen.getAllByRole('button')
|
||||||
|
await user.click(allButtons[0])
|
||||||
|
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||||
|
await user.type(tzInput, 'Invalid/Timezone')
|
||||||
|
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||||
|
await user.click(addButton)
|
||||||
|
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
// Default zones include New York (America/New_York)
|
||||||
|
const allButtons = screen.getAllByRole('button')
|
||||||
|
await user.click(allButtons[0])
|
||||||
|
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||||
|
await user.type(tzInput, 'America/New_York')
|
||||||
|
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||||
|
await user.click(addButton)
|
||||||
|
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||||
|
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
|
||||||
|
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
|
||||||
|
// Remove buttons for New York and Tokyo come after the Plus button
|
||||||
|
const allButtons = screen.getAllByRole('button')
|
||||||
|
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
|
||||||
|
await user.click(allButtons[1])
|
||||||
|
expect(screen.queryByText('New York')).toBeNull()
|
||||||
|
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
const allButtons = screen.getAllByRole('button')
|
||||||
|
await user.click(allButtons[0])
|
||||||
|
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||||
|
await user.click(berlinButton)
|
||||||
|
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
|
||||||
|
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<TimezoneWidget />)
|
||||||
|
const allButtons = screen.getAllByRole('button')
|
||||||
|
await user.click(allButtons[0])
|
||||||
|
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||||
|
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||||
|
await user.type(labelInput, 'Singapore')
|
||||||
|
await user.type(tzInput, 'Asia/Singapore')
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
expect(await screen.findByText('Singapore')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Clock, Plus, X } from 'lucide-react'
|
import { Clock, Plus, X } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
|
||||||
const POPULAR_ZONES = [
|
const POPULAR_ZONES = [
|
||||||
{ label: 'New York', tz: 'America/New_York' },
|
{ label: 'New York', tz: 'America/New_York' },
|
||||||
@@ -23,9 +24,9 @@ const POPULAR_ZONES = [
|
|||||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function getTime(tz, locale) {
|
function getTime(tz, locale, is12h) {
|
||||||
try {
|
try {
|
||||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit' })
|
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||||
} catch { return '—' }
|
} catch { return '—' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ function getOffset(tz) {
|
|||||||
|
|
||||||
export default function TimezoneWidget() {
|
export default function TimezoneWidget() {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const [zones, setZones] = useState(() => {
|
const [zones, setZones] = useState(() => {
|
||||||
const saved = localStorage.getItem('dashboard_timezones')
|
const saved = localStorage.getItem('dashboard_timezones')
|
||||||
return saved ? JSON.parse(saved) : [
|
return saved ? JSON.parse(saved) : [
|
||||||
@@ -87,7 +89,7 @@ export default function TimezoneWidget() {
|
|||||||
|
|
||||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||||
|
|
||||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||||
@@ -113,7 +115,7 @@ export default function TimezoneWidget() {
|
|||||||
{zones.map(z => (
|
{zones.map(z => (
|
||||||
<div key={z.tz} className="flex items-center justify-between group">
|
<div key={z.tz} className="flex items-center justify-between group">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale)}</p>
|
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||||
@@ -155,7 +157,7 @@ export default function TimezoneWidget() {
|
|||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
<span className="font-medium">{z.label}</span>
|
<span className="font-medium">{z.label}</span>
|
||||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale)}</span>
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012
|
||||||
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||||
|
import FileManager from './FileManager';
|
||||||
|
|
||||||
|
// Mock getAuthUrl
|
||||||
|
vi.mock('../../api/authUrl', () => ({
|
||||||
|
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock filesApi
|
||||||
|
vi.mock('../../api/client', async (importOriginal) => {
|
||||||
|
const original = (await importOriginal()) as any;
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
filesApi: {
|
||||||
|
list: vi.fn().mockResolvedValue({ files: [] }),
|
||||||
|
toggleStar: vi.fn().mockResolvedValue({}),
|
||||||
|
restore: vi.fn().mockResolvedValue({}),
|
||||||
|
permanentDelete: vi.fn().mockResolvedValue({}),
|
||||||
|
emptyTrash: vi.fn().mockResolvedValue({}),
|
||||||
|
upload: vi.fn().mockResolvedValue({ file: { id: 99 } }),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
addLink: vi.fn().mockResolvedValue({}),
|
||||||
|
removeLink: vi.fn().mockResolvedValue({}),
|
||||||
|
getLinks: vi.fn().mockResolvedValue({ links: [] }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { filesApi } from '../../api/client';
|
||||||
|
|
||||||
|
const buildFile = (overrides = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
original_name: 'report.pdf',
|
||||||
|
mime_type: 'application/pdf',
|
||||||
|
file_size: 51200,
|
||||||
|
created_at: '2025-01-10T08:00:00Z',
|
||||||
|
url: '/uploads/trips/1/report.pdf',
|
||||||
|
starred: false,
|
||||||
|
deleted_at: null,
|
||||||
|
place_id: null,
|
||||||
|
reservation_id: null,
|
||||||
|
day_id: null,
|
||||||
|
uploaded_by: 1,
|
||||||
|
uploader_name: 'Alice',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
files: [],
|
||||||
|
onUpload: vi.fn().mockResolvedValue({}),
|
||||||
|
onDelete: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
places: [],
|
||||||
|
days: [],
|
||||||
|
assignments: {},
|
||||||
|
reservations: [],
|
||||||
|
tripId: 1,
|
||||||
|
allowedFileTypes: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Seed auth as admin so useCanDo() returns true for all permissions
|
||||||
|
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||||
|
|
||||||
|
// Default trash endpoint
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/:tripId/files', ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.searchParams.get('trash') === 'true') {
|
||||||
|
return HttpResponse.json({ files: [] });
|
||||||
|
}
|
||||||
|
return HttpResponse.json({ files: [] });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stub window.confirm
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FileManager', () => {
|
||||||
|
it('FE-COMP-FILEMANAGER-001: renders empty state when no files', async () => {
|
||||||
|
render(<FileManager {...defaultProps} files={[]} />);
|
||||||
|
// The dropzone should be visible (Upload icon area)
|
||||||
|
expect(screen.getByText(/drop/i)).toBeInTheDocument();
|
||||||
|
// No file rows
|
||||||
|
expect(screen.queryByText('report.pdf')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-002: renders file list when files are provided', async () => {
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||||
|
expect(screen.getByText('report.pdf')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-003: file type filter tabs are present', async () => {
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||||
|
// Filter tabs should be present — match the button elements specifically
|
||||||
|
expect(screen.getByRole('button', { name: /^all$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^pdfs$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^images$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-004: images tab filters to image files only', async () => {
|
||||||
|
const files = [
|
||||||
|
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
|
||||||
|
buildFile({ id: 2, mime_type: 'application/pdf', original_name: 'doc.pdf' }),
|
||||||
|
];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
// Both should be visible initially
|
||||||
|
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click Images filter tab
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const imageTab = screen.getByRole('button', { name: /^images$/i });
|
||||||
|
await user.click(imageTab);
|
||||||
|
|
||||||
|
// Only photo should be visible
|
||||||
|
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => {
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Find the star button by its title
|
||||||
|
const starBtn = screen.getByTitle(/star/i);
|
||||||
|
await user.click(starBtn);
|
||||||
|
|
||||||
|
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
|
||||||
|
// filesApi.list is mocked — configure it to return trash files when called with trash=true
|
||||||
|
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||||
|
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||||
|
return Promise.resolve({ files: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} files={[]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Click trash toggle button
|
||||||
|
const trashBtn = screen.getByText(/trash/i);
|
||||||
|
await user.click(trashBtn);
|
||||||
|
|
||||||
|
// Trashed file should appear
|
||||||
|
await screen.findByText('old.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-007: restore button calls filesApi.restore', async () => {
|
||||||
|
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||||
|
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||||
|
return Promise.resolve({ files: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} files={[]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open trash
|
||||||
|
const trashBtn = screen.getByText(/trash/i);
|
||||||
|
await user.click(trashBtn);
|
||||||
|
await screen.findByText('old.pdf');
|
||||||
|
|
||||||
|
// Click restore button
|
||||||
|
const restoreBtn = screen.getByTitle(/restore/i);
|
||||||
|
await user.click(restoreBtn);
|
||||||
|
|
||||||
|
expect(filesApi.restore).toHaveBeenCalledWith(1, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-008: permanent delete calls filesApi.permanentDelete after confirm', async () => {
|
||||||
|
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||||
|
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||||
|
return Promise.resolve({ files: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} files={[]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open trash
|
||||||
|
await user.click(screen.getByText(/trash/i));
|
||||||
|
await screen.findByText('old.pdf');
|
||||||
|
|
||||||
|
// Click permanent delete (the Trash2 icon button in trash view)
|
||||||
|
const deleteBtn = screen.getByTitle(/delete/i);
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
|
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-009: empty trash calls filesApi.emptyTrash', async () => {
|
||||||
|
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||||
|
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||||
|
return Promise.resolve({ files: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} files={[]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open trash
|
||||||
|
await user.click(screen.getByText(/trash/i));
|
||||||
|
await screen.findByText('old.pdf');
|
||||||
|
|
||||||
|
// Click "Empty Trash" button
|
||||||
|
const emptyTrashBtn = await screen.findByText(/empty trash/i);
|
||||||
|
await user.click(emptyTrashBtn);
|
||||||
|
|
||||||
|
expect(filesApi.emptyTrash).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-010: image file click opens lightbox', async () => {
|
||||||
|
const files = [
|
||||||
|
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
|
||||||
|
];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Click the file name to open lightbox
|
||||||
|
await user.click(screen.getByText('photo.jpg'));
|
||||||
|
|
||||||
|
// Lightbox should appear — it has a fixed position overlay with the filename and a counter
|
||||||
|
await waitFor(() => {
|
||||||
|
// The lightbox header shows the filename and "1 / 1"
|
||||||
|
expect(screen.getByText('1 / 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-011: escape key closes lightbox', async () => {
|
||||||
|
const files = [
|
||||||
|
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
|
||||||
|
];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open lightbox
|
||||||
|
await user.click(screen.getByText('photo.jpg'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('1 / 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Press Escape
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
|
||||||
|
// Lightbox should be gone
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-013: soft-delete button calls onDelete', async () => {
|
||||||
|
const onDelete = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} onDelete={onDelete} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// The delete (trash) button on a non-trash row is titled 'Delete'
|
||||||
|
const deleteBtn = screen.getByTitle(/delete/i);
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
|
expect(onDelete).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-014: PDF file click opens preview modal', async () => {
|
||||||
|
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'report.pdf' })];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Click the file name — for a non-image this opens the PDF preview modal
|
||||||
|
await user.click(screen.getByText('report.pdf'));
|
||||||
|
|
||||||
|
// PDF preview modal should appear with the filename in the header
|
||||||
|
await waitFor(() => {
|
||||||
|
// The preview modal header shows the filename
|
||||||
|
const headers = screen.getAllByText('report.pdf');
|
||||||
|
expect(headers.length).toBeGreaterThanOrEqual(2); // in list + in modal header
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
|
||||||
|
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
|
||||||
|
// The AvatarChip shows the first letter of the name
|
||||||
|
expect(screen.getByText('A')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-016: multiple images in lightbox shows thumbnail strip', async () => {
|
||||||
|
const files = [
|
||||||
|
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo1.jpg' }),
|
||||||
|
buildFile({ id: 2, mime_type: 'image/jpeg', original_name: 'photo2.jpg' }),
|
||||||
|
];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open lightbox on first image
|
||||||
|
await user.click(screen.getByText('photo1.jpg'));
|
||||||
|
|
||||||
|
// Lightbox shows "1 / 2" counter
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('1 / 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-017: file size is displayed', () => {
|
||||||
|
const files = [buildFile({ file_size: 51200 })];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
expect(screen.getByText('50.0 KB')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
|
||||||
|
const files = [
|
||||||
|
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
|
||||||
|
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
|
||||||
|
];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// The starred filter tab only appears when there are starred files
|
||||||
|
const starredTab = screen.getByRole('button', { name: '' }); // Star icon button in filter tabs
|
||||||
|
await user.click(starredTab);
|
||||||
|
|
||||||
|
expect(screen.getByText('starred.pdf')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('normal.pdf')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-019: clicking assign button opens assign modal', async () => {
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Pencil/assign button
|
||||||
|
const assignBtn = screen.getByTitle(/assign/i);
|
||||||
|
await user.click(assignBtn);
|
||||||
|
|
||||||
|
// Assign modal should appear (it has a title and a close button)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/assign/i, { selector: 'div' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-020: assign modal shows places list', async () => {
|
||||||
|
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||||
|
const place = buildPlace({ id: 10, name: 'Eiffel Tower' });
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const assignBtn = screen.getByTitle(/assign/i);
|
||||||
|
await user.click(assignBtn);
|
||||||
|
|
||||||
|
await screen.findByText('Eiffel Tower');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-021: file description is shown when present', () => {
|
||||||
|
const files = [buildFile({ description: 'A very important document' })];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
expect(screen.getByText('A very important document')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-022: PDF preview modal can be closed', async () => {
|
||||||
|
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'report.pdf' })];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open preview
|
||||||
|
await user.click(screen.getByText('report.pdf'));
|
||||||
|
|
||||||
|
// Multiple 'report.pdf' elements now (list + modal header)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText('report.pdf').length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close via X button in the modal (second X button — first might be something else)
|
||||||
|
const closeButtons = screen.getAllByRole('button', { name: '' });
|
||||||
|
// Find a close button near the modal header — click the last X-like button
|
||||||
|
const xBtn = closeButtons.find(btn => btn.closest('[style*="z-index: 10000"]'));
|
||||||
|
if (xBtn) await user.click(xBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
|
||||||
|
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
|
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const assignBtn = screen.getByTitle(/assign/i);
|
||||||
|
await user.click(assignBtn);
|
||||||
|
|
||||||
|
await screen.findByText('Hotel Paris');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
|
||||||
|
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||||
|
const place = buildPlace({ id: 10, name: 'Louvre Museum' });
|
||||||
|
const file = buildFile({ id: 1 });
|
||||||
|
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<FileManager {...defaultProps} files={[file]} places={[place]} onUpdate={onUpdate} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open assign modal
|
||||||
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
|
await screen.findByText('Louvre Museum');
|
||||||
|
|
||||||
|
// Click on the place button to link it
|
||||||
|
await user.click(screen.getByText('Louvre Museum'));
|
||||||
|
|
||||||
|
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
|
||||||
|
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
|
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
|
||||||
|
const file = buildFile({ id: 1 });
|
||||||
|
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open assign modal
|
||||||
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
|
await screen.findByText('Train Ticket');
|
||||||
|
|
||||||
|
// Click on the reservation button to link it
|
||||||
|
await user.click(screen.getByText('Train Ticket'));
|
||||||
|
|
||||||
|
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
||||||
|
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
|
const place = buildPlace({ id: 10, name: 'Notre Dame' });
|
||||||
|
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
|
await screen.findByText('Notre Dame');
|
||||||
|
await screen.findByText('Airbnb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-027: paste event uploads file when user can upload', async () => {
|
||||||
|
const onUpload = vi.fn().mockResolvedValue({ file: { id: 55 } });
|
||||||
|
render(<FileManager {...defaultProps} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
const container = document.querySelector('.flex.flex-col') as HTMLElement;
|
||||||
|
const file = new File(['data'], 'pasted.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
// Manually build a paste event with a mock clipboardData.items
|
||||||
|
const mockItem = { kind: 'file', getAsFile: () => file };
|
||||||
|
const pasteEvent = new Event('paste', { bubbles: true });
|
||||||
|
Object.defineProperty(pasteEvent, 'clipboardData', {
|
||||||
|
value: { items: [mockItem] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent(container, pasteEvent);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-028: upload with places open assign modal after upload', async () => {
|
||||||
|
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||||
|
const place = buildPlace({ id: 10, name: 'Sagrada Familia' });
|
||||||
|
const onUpload = vi.fn().mockResolvedValue({ file: { id: 77 } });
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} onUpload={onUpload} places={[place]} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await userEvent.upload(input, file);
|
||||||
|
|
||||||
|
// After successful upload with places present, assign modal opens
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-029: assign modal with days+assignments shows day group', async () => {
|
||||||
|
const { buildPlace, buildDay } = await import('../../../tests/helpers/factories');
|
||||||
|
const place = buildPlace({ id: 10, name: 'Arc de Triomphe' });
|
||||||
|
const day = buildDay({ id: 5, date: '2025-06-01', day_number: 1 });
|
||||||
|
const assignments = { '5': [{ id: 1, day_id: 5, place_id: 10, order_index: 0, place }] };
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} days={[day]} assignments={assignments} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
|
await screen.findByText('Arc de Triomphe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-030: file with linked place shows source badge', async () => {
|
||||||
|
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||||
|
const place = buildPlace({ id: 10, name: 'Colosseum' });
|
||||||
|
const file = buildFile({ place_id: 10 });
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
|
||||||
|
|
||||||
|
// Source badge text includes place name
|
||||||
|
await screen.findByText(/Colosseum/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
|
||||||
|
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||||
|
const place = buildPlace({ id: 10, name: 'Venice Beach' });
|
||||||
|
// File already has place_id set to 10 (linked)
|
||||||
|
const file = buildFile({ id: 1, place_id: 10 });
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Open assign modal
|
||||||
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
|
await screen.findByText('Venice Beach');
|
||||||
|
|
||||||
|
// Clicking the linked place should unlink it
|
||||||
|
await user.click(screen.getByText('Venice Beach'));
|
||||||
|
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
|
||||||
|
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||||
|
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
|
||||||
|
// File already has reservation_id set to 20
|
||||||
|
const file = buildFile({ id: 1, reservation_id: 20 });
|
||||||
|
|
||||||
|
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle(/assign/i));
|
||||||
|
await screen.findByText('Museum Pass');
|
||||||
|
|
||||||
|
// Clicking the linked reservation should unlink it
|
||||||
|
await user.click(screen.getByText('Museum Pass'));
|
||||||
|
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
|
||||||
|
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'doc.pdf' })];
|
||||||
|
render(<FileManager {...defaultProps} files={files} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByText('doc.pdf'));
|
||||||
|
|
||||||
|
// Modal opens (multiple occurrences of doc.pdf)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText('doc.pdf').length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the backdrop to close
|
||||||
|
const backdrop = document.querySelector('[style*="z-index: 10000"]') as HTMLElement;
|
||||||
|
if (backdrop) await user.click(backdrop);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText('doc.pdf').length).toBeLessThan(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-FILEMANAGER-012: upload via dropzone calls onUpload', async () => {
|
||||||
|
const onUpload = vi.fn().mockResolvedValue({ file: { id: 99 } });
|
||||||
|
render(<FileManager {...defaultProps} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
// Find the hidden file input from the dropzone
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
|
||||||
|
const file = new File(['hello'], 'test.pdf', { type: 'application/pdf' });
|
||||||
|
|
||||||
|
await userEvent.upload(input, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onUpload).toHaveBeenCalled();
|
||||||
|
const call = onUpload.mock.calls[0];
|
||||||
|
expect(call[0]).toBeInstanceOf(FormData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { filesApi } from '../../api/client'
|
import { filesApi } from '../../api/client'
|
||||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
|
||||||
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload'
|
||||||
|
|
||||||
function isImage(mimeType) {
|
function isImage(mimeType) {
|
||||||
if (!mimeType) return false
|
if (!mimeType) return false
|
||||||
@@ -26,6 +31,10 @@ function formatSize(bytes) {
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerDownload(url: string, filename: string) {
|
||||||
|
downloadFile(url, filename).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateWithLocale(dateStr, locale) {
|
function formatDateWithLocale(dateStr, locale) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
try {
|
try {
|
||||||
@@ -33,41 +42,136 @@ function formatDateWithLocale(dateStr, locale) {
|
|||||||
} catch { return '' }
|
} catch { return '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image lightbox
|
// Image lightbox with gallery navigation
|
||||||
interface ImageLightboxProps {
|
interface ImageLightboxProps {
|
||||||
file: TripFile & { url: string }
|
files: (TripFile & { url: string })[]
|
||||||
|
initialIndex: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [index, setIndex] = useState(initialIndex)
|
||||||
|
const [imgSrc, setImgSrc] = useState('')
|
||||||
|
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||||
|
const file = files[index]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImgSrc('')
|
||||||
|
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||||
|
}, [file?.url])
|
||||||
|
|
||||||
|
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
||||||
|
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
if (e.key === 'ArrowLeft') goPrev()
|
||||||
|
if (e.key === 'ArrowRight') goNext()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!file) return null
|
||||||
|
|
||||||
|
const hasPrev = index > 0
|
||||||
|
const hasNext = index < files.length - 1
|
||||||
|
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
|
||||||
|
<button onClick={e => { e.stopPropagation(); onClick() }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
|
||||||
|
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||||
|
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
|
||||||
|
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||||
|
onTouchEnd={e => {
|
||||||
|
if (touchStart === null) return
|
||||||
|
const diff = e.changedTouches[0].clientX - touchStart
|
||||||
|
if (diff > 60) goPrev()
|
||||||
|
else if (diff < -60) goNext()
|
||||||
|
setTouchStart(null)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
{/* Header */}
|
||||||
<img
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
src={file.url}
|
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||||
alt={file.original_name}
|
{file.original_name}
|
||||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
||||||
/>
|
</span>
|
||||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
<button
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
||||||
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||||
<ExternalLink size={16} />
|
title={t('files.openTab')}>
|
||||||
</a>
|
<ExternalLink size={16} />
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
</button>
|
||||||
<X size={18} />
|
<button
|
||||||
</button>
|
onClick={() => triggerDownload(file.url, file.original_name)}
|
||||||
</div>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||||
|
title={t('files.download') || 'Download'}>
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Main image + nav */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
{navBtn('left', goPrev, hasPrev)}
|
||||||
|
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
||||||
|
{navBtn('right', goNext, hasNext)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail strip */}
|
||||||
|
{files.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
||||||
|
const [src, setSrc] = useState('')
|
||||||
|
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} style={{
|
||||||
|
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
|
||||||
|
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
|
||||||
|
}}>
|
||||||
|
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated image — fetches a short-lived download token and renders the image
|
||||||
|
function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
|
||||||
|
const [authSrc, setAuthSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||||
|
}, [src])
|
||||||
|
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
// Source badge
|
// Source badge
|
||||||
interface SourceBadgeProps {
|
interface SourceBadgeProps {
|
||||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||||
@@ -148,11 +252,13 @@ interface FileManagerProps {
|
|||||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [filterType, setFilterType] = useState('all')
|
const [filterType, setFilterType] = useState('all')
|
||||||
const [lightboxFile, setLightboxFile] = useState(null)
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||||
const [showTrash, setShowTrash] = useState(false)
|
const [showTrash, setShowTrash] = useState(false)
|
||||||
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
||||||
const [loadingTrash, setLoadingTrash] = useState(false)
|
const [loadingTrash, setLoadingTrash] = useState(false)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
|
||||||
const loadTrash = useCallback(async () => {
|
const loadTrash = useCallback(async () => {
|
||||||
@@ -247,6 +353,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handlePaste = useCallback((e) => {
|
const handlePaste = useCallback((e) => {
|
||||||
|
if (!can('file_upload', trip)) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
const pastedFiles = []
|
const pastedFiles = []
|
||||||
@@ -281,6 +388,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [previewFile, setPreviewFile] = useState(null)
|
const [previewFile, setPreviewFile] = useState(null)
|
||||||
|
const [previewFileUrl, setPreviewFileUrl] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
if (previewFile) {
|
||||||
|
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
|
||||||
|
} else {
|
||||||
|
setPreviewFileUrl('')
|
||||||
|
}
|
||||||
|
}, [previewFile?.url])
|
||||||
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||||
@@ -292,9 +407,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
|
||||||
|
|
||||||
const openFile = (file) => {
|
const openFile = (file) => {
|
||||||
if (isImage(file.mime_type)) {
|
if (isImage(file.mime_type)) {
|
||||||
setLightboxFile(file)
|
const idx = imageFiles.findIndex(f => f.id === file.id)
|
||||||
|
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||||
} else {
|
} else {
|
||||||
setPreviewFile(file)
|
setPreviewFile(file)
|
||||||
}
|
}
|
||||||
@@ -302,12 +420,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
|
|
||||||
const renderFileRow = (file: TripFile, isTrash = false) => {
|
const renderFileRow = (file: TripFile, isTrash = false) => {
|
||||||
const FileIcon = getFileIcon(file.mime_type)
|
const FileIcon = getFileIcon(file.mime_type)
|
||||||
const linkedPlace = places?.find(p => p.id === file.place_id)
|
const allLinkedPlaceIds = new Set<number>()
|
||||||
const linkedReservation = file.reservation_id
|
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
||||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
||||||
: null
|
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
||||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
// All linked reservations (primary + file_links)
|
||||||
|
const allLinkedResIds = new Set<number>()
|
||||||
|
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||||
|
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||||
|
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||||
return (
|
return (
|
||||||
<div key={file.id} style={{
|
<div key={file.id} style={{
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
@@ -321,7 +442,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
>
|
>
|
||||||
{/* Icon or thumbnail */}
|
{/* Icon or thumbnail */}
|
||||||
<div
|
<div
|
||||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
onClick={() => !isTrash && openFile(file)}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -329,7 +450,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isImage(file.mime_type)
|
{isImage(file.mime_type)
|
||||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
: (() => {
|
: (() => {
|
||||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||||
const isPdf = file.mime_type === 'application/pdf'
|
const isPdf = file.mime_type === 'application/pdf'
|
||||||
@@ -350,7 +471,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
)}
|
)}
|
||||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||||
<span
|
<span
|
||||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
onClick={() => !isTrash && openFile(file)}
|
||||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||||
>
|
>
|
||||||
{file.original_name}
|
{file.original_name}
|
||||||
@@ -365,12 +486,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||||
|
|
||||||
{linkedPlace && (
|
{linkedPlaces.map(p => (
|
||||||
<SourceBadge icon={MapPin} label={`${t('files.sourcePlan')} · ${linkedPlace.name}`} />
|
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||||
)}
|
))}
|
||||||
{linkedReservation && (
|
{linkedReservations.map(r => (
|
||||||
<SourceBadge icon={Ticket} label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`} />
|
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||||
)}
|
))}
|
||||||
{file.note_id && (
|
{file.note_id && (
|
||||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||||
)}
|
)}
|
||||||
@@ -381,14 +502,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||||
{isTrash ? (
|
{isTrash ? (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -396,18 +517,22 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||||
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
<button onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +543,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
||||||
{/* Lightbox */}
|
{/* Lightbox */}
|
||||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||||
|
|
||||||
{/* Assign modal */}
|
{/* Assign modal */}
|
||||||
{assignFileId && ReactDOM.createPortal(
|
{assignFileId && ReactDOM.createPortal(
|
||||||
@@ -477,20 +602,45 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||||
const placeBtn = (p: Place) => (
|
const placeBtn = (p: Place) => {
|
||||||
<button key={p.id} onClick={() => handleAssign(file.id, { place_id: file.place_id === p.id ? null : p.id })} style={{
|
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
return (
|
||||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
<button key={p.id} onClick={async () => {
|
||||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.place_id === p.id ? 600 : 400,
|
if (isLinked) {
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
if (file.place_id === p.id) {
|
||||||
}}
|
await handleAssign(file.id, { place_id: null })
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
} else {
|
||||||
onMouseLeave={e => e.currentTarget.style.background = file.place_id === p.id ? 'var(--bg-hover)' : 'transparent'}>
|
try {
|
||||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||||
{file.place_id === p.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
</button>
|
refreshFiles()
|
||||||
)
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!file.place_id) {
|
||||||
|
await handleAssign(file.id, { place_id: p.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const placesSection = places.length > 0 && (
|
const placesSection = places.length > 0 && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -499,8 +649,17 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
{dayGroups.map(({ day, dayPlaces }) => (
|
{dayGroups.map(({ day, dayPlaces }) => (
|
||||||
<div key={day.id}>
|
<div key={day.id}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||||
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
|
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
||||||
|
{(() => {
|
||||||
|
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
|
||||||
|
return badge ? (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||||
|
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
|
||||||
|
}}>{badge}</span>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{dayPlaces.map(placeBtn)}
|
{dayPlaces.map(placeBtn)}
|
||||||
</div>
|
</div>
|
||||||
@@ -519,20 +678,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{t('files.assignBooking')}
|
{t('files.assignBooking')}
|
||||||
</div>
|
</div>
|
||||||
{reservations.map(r => (
|
{reservations.map(r => {
|
||||||
<button key={r.id} onClick={() => handleAssign(file.id, { reservation_id: file.reservation_id === r.id ? null : r.id })} style={{
|
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.reservation_id === r.id ? 'var(--bg-hover)' : 'none',
|
return (
|
||||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
<button key={r.id} onClick={async () => {
|
||||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.reservation_id === r.id ? 600 : 400,
|
if (isLinked) {
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
||||||
}}
|
if (file.reservation_id === r.id) {
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
await handleAssign(file.id, { reservation_id: null })
|
||||||
onMouseLeave={e => e.currentTarget.style.background = file.reservation_id === r.id ? 'var(--bg-hover)' : 'transparent'}>
|
} else {
|
||||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
try {
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
{file.reservation_id === r.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||||
</button>
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
))}
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Link: if no primary, set it; otherwise use file_links
|
||||||
|
if (!file.reservation_id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: r.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -565,12 +751,20 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
<button
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||||
<ExternalLink size={13} /> {t('files.openTab')}
|
<ExternalLink size={13} /> {t('files.openTab')}
|
||||||
</a>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||||
|
<Download size={13} /> {t('files.download') || 'Download'}
|
||||||
|
</button>
|
||||||
<button onClick={() => setPreviewFile(null)}
|
<button onClick={() => setPreviewFile(null)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
@@ -580,13 +774,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<object
|
<object
|
||||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||||
type="application/pdf"
|
type="application/pdf"
|
||||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||||
title={previewFile.original_name}
|
title={previewFile.original_name}
|
||||||
>
|
>
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,31 +788,87 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Toolbar */}
|
||||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
|
||||||
<div>
|
<div style={{
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
|
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
padding: '14px 16px 14px 22px',
|
||||||
{showTrash
|
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||||
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
|
|
||||||
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={toggleTrash} style={{
|
|
||||||
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
|
||||||
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
|
|
||||||
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
|
|
||||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}>
|
}}>
|
||||||
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
</button>
|
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!showTrash && (
|
||||||
|
<>
|
||||||
|
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||||
|
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: t('files.filterAll') },
|
||||||
|
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
|
||||||
|
{ id: 'pdf', label: t('files.filterPdf') },
|
||||||
|
{ id: 'image', label: t('files.filterImages') },
|
||||||
|
{ id: 'doc', label: t('files.filterDocs') },
|
||||||
|
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||||
|
].map(tab => {
|
||||||
|
const active = filterType === tab.id
|
||||||
|
const TabIcon = 'icon' in tab ? tab.icon : null
|
||||||
|
const count = tab.id === 'all' ? files.length
|
||||||
|
: tab.id === 'starred' ? files.filter(f => f.starred).length
|
||||||
|
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
|
||||||
|
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
|
||||||
|
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
|
||||||
|
: tab.id === 'collab' ? files.filter(f => f.note_id).length
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<button key={tab.id} onClick={() => setFilterType(tab.id)}
|
||||||
|
style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||||
|
background: active ? 'var(--bg-card)' : 'transparent',
|
||||||
|
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
fontWeight: active ? 500 : 400,
|
||||||
|
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
|
||||||
|
{'label' in tab && tab.label}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600,
|
||||||
|
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||||
|
color: 'var(--text-faint)',
|
||||||
|
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||||
|
}}>{count}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={toggleTrash} style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
|
background: 'var(--accent)', color: 'var(--accent-text)',
|
||||||
|
flexShrink: 0, marginLeft: 'auto',
|
||||||
|
opacity: showTrash ? 1 : 0.88,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTrash ? (
|
{showTrash ? (
|
||||||
/* Trash view */
|
/* Trash view */
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||||
{trashFiles.length > 0 && (
|
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||||
<button onClick={handleEmptyTrash} style={{
|
<button onClick={handleEmptyTrash} style={{
|
||||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||||
@@ -647,10 +897,10 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Upload zone */}
|
{/* Upload zone */}
|
||||||
<div
|
{can('file_upload', trip) && <div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
style={{
|
style={{
|
||||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||||
@@ -672,10 +922,10 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Filter tabs */}
|
{/* Filter tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
{[
|
{[
|
||||||
{ id: 'all', label: t('files.filterAll') },
|
{ id: 'all', label: t('files.filterAll') },
|
||||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
||||||
@@ -698,7 +948,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File list */}
|
{/* File list */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||||
{filteredFiles.length === 0 ? (
|
{filteredFiles.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import JournalBody from './JournalBody';
|
||||||
|
|
||||||
|
describe('JournalBody', () => {
|
||||||
|
it('FE-COMP-JOURNALBODY-001: renders plain text content', () => {
|
||||||
|
render(<JournalBody text="Hello traveller" />);
|
||||||
|
expect(screen.getByText('Hello traveller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNALBODY-002: renders bold markdown as <strong>', () => {
|
||||||
|
const { container } = render(<JournalBody text="This is **bold** text" />);
|
||||||
|
const strong = container.querySelector('strong');
|
||||||
|
expect(strong).toBeInTheDocument();
|
||||||
|
expect(strong!.textContent).toBe('bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => {
|
||||||
|
render(<JournalBody text="[Visit](https://example.com)" />);
|
||||||
|
const link = screen.getByRole('link', { name: 'Visit' });
|
||||||
|
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
|
||||||
|
const { container } = render(<JournalBody text="## Section Title" />);
|
||||||
|
const p = container.querySelector('p');
|
||||||
|
expect(p).toBeInTheDocument();
|
||||||
|
expect(p!.textContent).toBe('Section Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => {
|
||||||
|
const { container } = render(<JournalBody text="" />);
|
||||||
|
expect(container.querySelector('.journal-body')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkBreaks from 'remark-breaks'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
dark?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JournalBody({ text, dark }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="journal-body" style={{
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: 'inherit',
|
||||||
|
}}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||||
|
components={{
|
||||||
|
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||||
|
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||||
|
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||||
|
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote style={{
|
||||||
|
borderLeft: `3px solid var(--journal-accent)`,
|
||||||
|
paddingLeft: 16, margin: '12px 0',
|
||||||
|
fontStyle: 'italic', color: 'var(--journal-muted)',
|
||||||
|
}}>{children}</blockquote>
|
||||||
|
),
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => <ul style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ol>,
|
||||||
|
li: ({ children }) => <li style={{ margin: '4px 0' }}>{children}</li>,
|
||||||
|
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
|
||||||
|
em: ({ children }) => <em>{children}</em>,
|
||||||
|
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
|
||||||
|
code: ({ children, className }) => {
|
||||||
|
const isBlock = className?.includes('language-')
|
||||||
|
if (isBlock) {
|
||||||
|
return (
|
||||||
|
<pre style={{
|
||||||
|
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
|
||||||
|
borderRadius: 8, padding: 14, overflowX: 'auto',
|
||||||
|
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
|
||||||
|
}}>
|
||||||
|
<code>{children}</code>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code style={{
|
||||||
|
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
|
||||||
|
borderRadius: 4, padding: '2px 5px', fontSize: '0.9em', fontFamily: 'monospace',
|
||||||
|
}}>{children}</code>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-006
|
||||||
|
|
||||||
|
vi.mock('../../api/websocket', () => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
getSocketId: vi.fn(() => null),
|
||||||
|
setRefetchCallback: vi.fn(),
|
||||||
|
setPreReconnectHook: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Leaflet does not work in jsdom — mock the entire library
|
||||||
|
vi.mock('leaflet', () => {
|
||||||
|
const mockMarker = {
|
||||||
|
addTo: vi.fn().mockReturnThis(),
|
||||||
|
bindTooltip: vi.fn().mockReturnThis(),
|
||||||
|
on: vi.fn().mockReturnThis(),
|
||||||
|
setIcon: vi.fn(),
|
||||||
|
setZIndexOffset: vi.fn(),
|
||||||
|
getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||||
|
};
|
||||||
|
const mockMap = {
|
||||||
|
remove: vi.fn(),
|
||||||
|
invalidateSize: vi.fn(),
|
||||||
|
fitBounds: vi.fn(),
|
||||||
|
setView: vi.fn(),
|
||||||
|
flyTo: vi.fn(),
|
||||||
|
getZoom: vi.fn(() => 10),
|
||||||
|
zoomIn: vi.fn(),
|
||||||
|
zoomOut: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
map: vi.fn(() => mockMap),
|
||||||
|
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||||
|
marker: vi.fn(() => mockMarker),
|
||||||
|
polyline: vi.fn(() => ({ addTo: vi.fn() })),
|
||||||
|
divIcon: vi.fn(() => ({})),
|
||||||
|
latLngBounds: vi.fn(() => ({})),
|
||||||
|
},
|
||||||
|
map: vi.fn(() => mockMap),
|
||||||
|
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||||
|
marker: vi.fn(() => mockMarker),
|
||||||
|
polyline: vi.fn(() => ({ addTo: vi.fn() })),
|
||||||
|
divIcon: vi.fn(() => ({})),
|
||||||
|
latLngBounds: vi.fn(() => ({})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render } from '../../../tests/helpers/render';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { buildSettings } from '../../../tests/helpers/factories';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import JourneyMap from './JourneyMap';
|
||||||
|
import type { JourneyMapHandle } from './JourneyMap';
|
||||||
|
|
||||||
|
const entriesWithCoords = [
|
||||||
|
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||||
|
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const entriesWithoutCoords = [
|
||||||
|
{ id: 'e3', lat: 0, lng: 0, title: 'Unknown Place', mood: null, entry_date: '2025-06-03' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mixedEntries = [
|
||||||
|
...entriesWithCoords,
|
||||||
|
...entriesWithoutCoords,
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyMap', () => {
|
||||||
|
it('FE-COMP-JOURNEYMAP-001: renders map container', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||||
|
);
|
||||||
|
// The component renders a div with a child div ref for the Leaflet map
|
||||||
|
expect(container.firstChild).toBeInTheDocument();
|
||||||
|
expect(L.map).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-002: renders markers for entries with coordinates', () => {
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||||
|
);
|
||||||
|
// Two entries with valid lat/lng should produce two markers
|
||||||
|
expect(L.marker).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-003: does not render markers for entries without coordinates', () => {
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithoutCoords} />
|
||||||
|
);
|
||||||
|
// Entry with lat=0 and lng=0 is filtered out by buildMarkerItems (if (e.lat && e.lng))
|
||||||
|
expect(L.marker).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-004: renders polyline connecting entries', () => {
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||||
|
);
|
||||||
|
// With 2+ marker items, a route polyline is drawn
|
||||||
|
expect(L.polyline).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-005: shows entry title in marker tooltip', () => {
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||||
|
);
|
||||||
|
// Each marker calls bindTooltip with the entry label
|
||||||
|
const mockMarkerInstance = (L.marker as any).mock.results[0].value;
|
||||||
|
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith(
|
||||||
|
'Paris',
|
||||||
|
expect.objectContaining({ direction: 'top' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => {
|
||||||
|
const ref = React.createRef<JourneyMapHandle>();
|
||||||
|
render(
|
||||||
|
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />
|
||||||
|
);
|
||||||
|
expect(ref.current).not.toBeNull();
|
||||||
|
expect(typeof ref.current!.focusMarker).toBe('function');
|
||||||
|
expect(typeof ref.current!.highlightMarker).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-007: renders SVG pin markers via divIcon', () => {
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||||
|
);
|
||||||
|
// Each marker is created with L.divIcon containing SVG html
|
||||||
|
expect(L.divIcon).toHaveBeenCalledTimes(2);
|
||||||
|
const firstCall = (L.divIcon as any).mock.calls[0][0];
|
||||||
|
expect(firstCall.html).toContain('<svg');
|
||||||
|
expect(firstCall.html).toContain('</svg>');
|
||||||
|
// Marker index label "1" for first entry
|
||||||
|
expect(firstCall.html).toContain('>1<');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => {
|
||||||
|
const entriesWithMood = [
|
||||||
|
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' },
|
||||||
|
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Sad Berlin', mood: 'sad', entry_date: '2025-06-02' },
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithMood} />
|
||||||
|
);
|
||||||
|
// Markers are still created (mood does not prevent rendering)
|
||||||
|
expect(L.marker).toHaveBeenCalledTimes(2);
|
||||||
|
// Tooltips use the entry titles
|
||||||
|
const mockMarker1 = (L.marker as any).mock.results[0].value;
|
||||||
|
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith(
|
||||||
|
'Happy Paris',
|
||||||
|
expect.objectContaining({ direction: 'top' }),
|
||||||
|
);
|
||||||
|
const mockMarker2 = (L.marker as any).mock.results[1].value;
|
||||||
|
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith(
|
||||||
|
'Sad Berlin',
|
||||||
|
expect.objectContaining({ direction: 'top' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-009: draws route polyline connecting multiple markers', () => {
|
||||||
|
const threeEntries = [
|
||||||
|
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||||
|
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
|
||||||
|
{ id: 'e3', lat: 41.9028, lng: 12.4964, title: 'Rome', mood: null, entry_date: '2025-06-03' },
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={threeEntries} />
|
||||||
|
);
|
||||||
|
// Route polyline is drawn for items.length > 1
|
||||||
|
expect(L.polyline).toHaveBeenCalled();
|
||||||
|
const polylineCall = (L.polyline as any).mock.calls[0];
|
||||||
|
// Should contain coordinates for all three entries
|
||||||
|
expect(polylineCall[0].length).toBe(3);
|
||||||
|
// Verify dashed style
|
||||||
|
expect(polylineCall[1]).toMatchObject({ dashArray: '4 6' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-010: fitBounds is called for auto-zoom', () => {
|
||||||
|
// Trigger requestAnimationFrame synchronously
|
||||||
|
const origRAF = globalThis.requestAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockMap = (L.map as any).mock.results[0].value;
|
||||||
|
// fitBounds is called inside requestAnimationFrame with the collected coordinates
|
||||||
|
expect(mockMap.fitBounds).toHaveBeenCalled();
|
||||||
|
expect(L.latLngBounds).toHaveBeenCalled();
|
||||||
|
|
||||||
|
globalThis.requestAnimationFrame = origRAF;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-011: single entry creates marker but no polyline', () => {
|
||||||
|
const singleEntry = [
|
||||||
|
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Solo Paris', mood: null, entry_date: '2025-06-01' },
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<JourneyMap checkins={[]} entries={singleEntry} />
|
||||||
|
);
|
||||||
|
// One marker created
|
||||||
|
expect(L.marker).toHaveBeenCalledTimes(1);
|
||||||
|
// No route polyline — polyline is only drawn when items.length > 1
|
||||||
|
expect(L.polyline).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-JOURNEYMAP-012: renders zoom control buttons', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||||
|
);
|
||||||
|
// The component renders zoom in (+) and zoom out (−) buttons
|
||||||
|
const buttons = container.querySelectorAll('button');
|
||||||
|
expect(buttons.length).toBe(2);
|
||||||
|
expect(buttons[0].textContent).toBe('+');
|
||||||
|
expect(buttons[1].textContent).toBe('−');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
|
||||||
|
export interface MapMarkerItem {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
label: string
|
||||||
|
mood?: string | null
|
||||||
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JourneyMapHandle {
|
||||||
|
highlightMarker: (id: string | null) => void
|
||||||
|
focusMarker: (id: string) => void
|
||||||
|
invalidateSize: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapEntry {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
title?: string | null
|
||||||
|
mood?: string | null
|
||||||
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checkins: any[]
|
||||||
|
entries: MapEntry[]
|
||||||
|
trail?: { lat: number; lng: number }[]
|
||||||
|
height?: number
|
||||||
|
dark?: boolean
|
||||||
|
activeMarkerId?: string | null
|
||||||
|
onMarkerClick?: (id: string, type?: string) => void
|
||||||
|
fullScreen?: boolean
|
||||||
|
paddingBottom?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||||
|
const items: MapMarkerItem[] = []
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.lat && e.lng) {
|
||||||
|
items.push({
|
||||||
|
id: e.id,
|
||||||
|
lat: e.lat,
|
||||||
|
lng: e.lng,
|
||||||
|
label: e.title || 'Entry',
|
||||||
|
mood: e.mood,
|
||||||
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_W = 28
|
||||||
|
const MARKER_H = 36
|
||||||
|
|
||||||
|
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
||||||
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
|
const shadow = highlighted
|
||||||
|
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||||
|
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
|
const label = String(dayLabel)
|
||||||
|
const scale = highlighted ? 1.2 : 1
|
||||||
|
|
||||||
|
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
||||||
|
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
|
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||||
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
|
</svg>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||||
|
|
||||||
|
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||||
|
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const stableTrail = trail || EMPTY_TRAIL
|
||||||
|
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<L.Map | null>(null)
|
||||||
|
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
||||||
|
const itemsRef = useRef<MapMarkerItem[]>([])
|
||||||
|
const highlightedRef = useRef<string | null>(null)
|
||||||
|
const onMarkerClickRef = useRef(onMarkerClick)
|
||||||
|
onMarkerClickRef.current = onMarkerClick
|
||||||
|
|
||||||
|
const darkRef = useRef(dark)
|
||||||
|
darkRef.current = dark
|
||||||
|
|
||||||
|
const highlightMarker = useCallback((id: string | null) => {
|
||||||
|
const prev = highlightedRef.current
|
||||||
|
highlightedRef.current = id
|
||||||
|
const isDark = !!darkRef.current
|
||||||
|
|
||||||
|
if (prev && prev !== id) {
|
||||||
|
const marker = markersRef.current.get(prev)
|
||||||
|
const item = itemsRef.current.find(i => i.id === prev)
|
||||||
|
if (marker && item) {
|
||||||
|
marker.setIcon(L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
|
}))
|
||||||
|
marker.setZIndexOffset(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const marker = markersRef.current.get(id)
|
||||||
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
|
if (marker && item) {
|
||||||
|
marker.setIcon(L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
|
html: markerSvg(item.dayColor, item.dayLabel, true),
|
||||||
|
}))
|
||||||
|
marker.setZIndexOffset(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const focusMarker = useCallback((id: string) => {
|
||||||
|
highlightMarker(id)
|
||||||
|
const marker = markersRef.current.get(id)
|
||||||
|
if (marker && mapRef.current) {
|
||||||
|
try {
|
||||||
|
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||||
|
} catch { /* map not yet initialized */ }
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const invalidateSize = useCallback(() => {
|
||||||
|
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove()
|
||||||
|
mapRef.current = null
|
||||||
|
}
|
||||||
|
markersRef.current.clear()
|
||||||
|
|
||||||
|
const map = L.map(containerRef.current, {
|
||||||
|
zoomControl: false,
|
||||||
|
attributionControl: true,
|
||||||
|
scrollWheelZoom: fullScreen ? true : false,
|
||||||
|
dragging: true,
|
||||||
|
touchZoom: true,
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
|
||||||
|
const defaultTile = dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||||||
|
L.tileLayer(mapTileUrl || defaultTile, {
|
||||||
|
maxZoom: 18,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||||
|
// Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle
|
||||||
|
// before loading tiles). On the journey mobile combined view we flyTo
|
||||||
|
// constantly when switching cards, so tiles lag visibly — force eager
|
||||||
|
// updates and keep a larger ring of off-screen tiles ready.
|
||||||
|
updateWhenIdle: false,
|
||||||
|
keepBuffer: 4,
|
||||||
|
} as any).addTo(map)
|
||||||
|
|
||||||
|
const items = buildMarkerItems(entries)
|
||||||
|
itemsRef.current = items
|
||||||
|
|
||||||
|
const allCoords: L.LatLngTuple[] = []
|
||||||
|
|
||||||
|
if (stableTrail.length > 1) {
|
||||||
|
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
||||||
|
L.polyline(coords, {
|
||||||
|
color: '#6366f1', weight: 3, opacity: 0.4,
|
||||||
|
dashArray: '6 4', lineCap: 'round',
|
||||||
|
}).addTo(map)
|
||||||
|
coords.forEach(c => allCoords.push(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
// route polyline — only in non-fullscreen (sidebar map) mode
|
||||||
|
if (!fullScreen && items.length > 1) {
|
||||||
|
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||||
|
L.polyline(routeCoords, {
|
||||||
|
color: dark ? '#71717A' : '#A1A1AA',
|
||||||
|
weight: 1.5,
|
||||||
|
opacity: 0.5,
|
||||||
|
dashArray: '4 6',
|
||||||
|
lineCap: 'round', lineJoin: 'round',
|
||||||
|
}).addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// place markers
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
const pos: L.LatLngTuple = [item.lat, item.lng]
|
||||||
|
allCoords.push(pos)
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
|
})
|
||||||
|
|
||||||
|
const marker = L.marker(pos, { icon }).addTo(map)
|
||||||
|
marker.bindTooltip(item.label, {
|
||||||
|
direction: 'top',
|
||||||
|
offset: [0, -MARKER_H],
|
||||||
|
className: 'map-tooltip',
|
||||||
|
})
|
||||||
|
|
||||||
|
marker.on('click', () => {
|
||||||
|
onMarkerClickRef.current?.(item.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
markersRef.current.set(item.id, marker)
|
||||||
|
})
|
||||||
|
|
||||||
|
// fit bounds
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!mapRef.current) return
|
||||||
|
try {
|
||||||
|
map.invalidateSize()
|
||||||
|
if (allCoords.length > 0) {
|
||||||
|
const pb = paddingBottom || 50
|
||||||
|
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||||||
|
} else {
|
||||||
|
map.setView([30, 0], 2)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mapRef.current) map.invalidateSize()
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove()
|
||||||
|
mapRef.current = null
|
||||||
|
markersRef.current.clear()
|
||||||
|
}
|
||||||
|
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||||
|
|
||||||
|
// react to activeMarkerId prop changes — runs after map is built
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeMarkerId || !mapRef.current) return
|
||||||
|
// small delay to ensure markers are rendered after map build
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
highlightMarker(activeMarkerId)
|
||||||
|
const marker = markersRef.current.get(activeMarkerId)
|
||||||
|
if (!marker || !mapRef.current) return
|
||||||
|
// fitBounds may still be pending when this fires — getZoom() throws
|
||||||
|
// "Set map center and zoom first" until the map has a view. Guard it.
|
||||||
|
try {
|
||||||
|
const currentZoom = mapRef.current.getZoom()
|
||||||
|
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
|
||||||
|
} catch {
|
||||||
|
mapRef.current.setView(marker.getLatLng(), 12)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [activeMarkerId])
|
||||||
|
|
||||||
|
const zoomIn = () => mapRef.current?.zoomIn()
|
||||||
|
const zoomOut = () => mapRef.current?.zoomOut()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={zoomIn}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: 8,
|
||||||
|
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||||
|
color: dark ? '#fff' : '#18181B',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>+</button>
|
||||||
|
<button
|
||||||
|
onClick={zoomOut}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: 8,
|
||||||
|
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||||
|
color: dark ? '#fff' : '#18181B',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>−</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default JourneyMap
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
|
||||||
|
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
|
||||||
|
|
||||||
|
// Unified handle — both providers expose the same three methods.
|
||||||
|
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||||
|
|
||||||
|
interface MapEntry {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
title?: string | null
|
||||||
|
location_name?: string | null
|
||||||
|
mood?: string | null
|
||||||
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checkins: unknown[]
|
||||||
|
entries: MapEntry[]
|
||||||
|
trail?: { lat: number; lng: number }[]
|
||||||
|
height?: number
|
||||||
|
dark?: boolean
|
||||||
|
activeMarkerId?: string | null
|
||||||
|
onMarkerClick?: (id: string, type?: string) => void
|
||||||
|
fullScreen?: boolean
|
||||||
|
paddingBottom?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyMapAuto(props, ref) {
|
||||||
|
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||||
|
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||||
|
const leafletRef = useRef<JourneyMapHandle>(null)
|
||||||
|
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||||
|
|
||||||
|
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||||
|
// supplied a token yet — otherwise the map would just show a stub.
|
||||||
|
const useGL = provider === 'mapbox-gl' && !!token
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||||
|
focusMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.focusMarker(id),
|
||||||
|
invalidateSize: () => (useGL ? glRef.current : leafletRef.current)?.invalidateSize(),
|
||||||
|
}), [useGL])
|
||||||
|
|
||||||
|
if (useGL) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return <JourneyMapGL ref={glRef} {...(props as any)} />
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return <JourneyMap ref={leafletRef} {...(props as any)} />
|
||||||
|
})
|
||||||
|
|
||||||
|
export default JourneyMapAuto
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||||
|
import mapboxgl from 'mapbox-gl'
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||||
|
|
||||||
|
export interface JourneyMapGLHandle {
|
||||||
|
highlightMarker: (id: string | null) => void
|
||||||
|
focusMarker: (id: string) => void
|
||||||
|
invalidateSize: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapEntry {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
title?: string | null
|
||||||
|
location_name?: string | null
|
||||||
|
mood?: string | null
|
||||||
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checkins: unknown[]
|
||||||
|
entries: MapEntry[]
|
||||||
|
trail?: { lat: number; lng: number }[]
|
||||||
|
height?: number
|
||||||
|
dark?: boolean
|
||||||
|
activeMarkerId?: string | null
|
||||||
|
onMarkerClick?: (id: string, type?: string) => void
|
||||||
|
fullScreen?: boolean
|
||||||
|
paddingBottom?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
label: string
|
||||||
|
locationName: string
|
||||||
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_W = 28
|
||||||
|
const MARKER_H = 36
|
||||||
|
|
||||||
|
function buildItems(entries: MapEntry[]): Item[] {
|
||||||
|
const items: Item[] = []
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.lat && e.lng) {
|
||||||
|
items.push({
|
||||||
|
id: e.id,
|
||||||
|
lat: e.lat,
|
||||||
|
lng: e.lng,
|
||||||
|
label: e.title || '',
|
||||||
|
locationName: e.location_name || '',
|
||||||
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntryDate(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
try {
|
||||||
|
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the popup styles once per document. Two-line frosted-glass card in
|
||||||
|
// the Apple/Google Maps idiom — title on top, location / date subtly below.
|
||||||
|
function ensureJourneyPopupStyle() {
|
||||||
|
if (document.getElementById('trek-journey-popup-style')) return
|
||||||
|
const s = document.createElement('style')
|
||||||
|
s.id = 'trek-journey-popup-style'
|
||||||
|
s.textContent = `
|
||||||
|
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||||
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
|
||||||
|
padding: 9px 14px 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
|
font-family: -apple-system, system-ui, sans-serif;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
|
||||||
|
background: rgba(24, 24, 27, 0.88);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #FAFAFA;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.94);
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
|
||||||
|
border-top-color: rgba(24, 24, 27, 0.88);
|
||||||
|
border-bottom-color: rgba(24, 24, 27, 0.88);
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
||||||
|
.trek-journey-popup-title {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: #18181B;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||||
|
.trek-journey-popup-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #71717A;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||||
|
.trek-journey-popup-place {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.trek-journey-popup-sep {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: 0.55;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.trek-journey-popup-date { flex: 0 0 auto; }
|
||||||
|
@keyframes trek-journey-popup-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||||
|
const fill = dayColor
|
||||||
|
const textColor = '#fff'
|
||||||
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
|
const shadow = highlighted
|
||||||
|
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||||
|
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
|
const scale = highlighted ? 1.2 : 1
|
||||||
|
const label = String(dayLabel)
|
||||||
|
|
||||||
|
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||||
|
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||||
|
// the CSS transition would catch the map's per-frame translate updates and
|
||||||
|
// the marker smears all over the viewport while scrolling / flying.
|
||||||
|
const wrap = document.createElement('div')
|
||||||
|
wrap.style.cssText = `width:${MARKER_W}px;height:${MARKER_H}px;cursor:pointer;`
|
||||||
|
const inner = document.createElement('div')
|
||||||
|
inner.className = 'trek-journey-marker-inner'
|
||||||
|
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
||||||
|
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
|
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||||
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
|
</svg>`
|
||||||
|
wrap.appendChild(inner)
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||||
|
|
||||||
|
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
||||||
|
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const stableTrail = trail || EMPTY_TRAIL
|
||||||
|
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
|
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||||
|
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||||
|
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
|
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
||||||
|
const itemsRef = useRef<Item[]>([])
|
||||||
|
const highlightedRef = useRef<string | null>(null)
|
||||||
|
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||||
|
const onMarkerClickRef = useRef(onMarkerClick)
|
||||||
|
onMarkerClickRef.current = onMarkerClick
|
||||||
|
const darkRef = useRef(dark)
|
||||||
|
darkRef.current = dark
|
||||||
|
|
||||||
|
const showPopup = useCallback((id: string) => {
|
||||||
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
|
if (!item || !mapRef.current) return
|
||||||
|
ensureJourneyPopupStyle()
|
||||||
|
// Primary line: user-given title. If none, fall back to the location
|
||||||
|
// name so we always show *something* useful on the top line.
|
||||||
|
const primaryRaw = item.label || item.locationName || 'Entry'
|
||||||
|
const secondaryPlace = item.label ? item.locationName : ''
|
||||||
|
const dateStr = formatEntryDate(item.time)
|
||||||
|
const primary = escapeHtml(primaryRaw)
|
||||||
|
const place = escapeHtml(secondaryPlace)
|
||||||
|
const date = escapeHtml(dateStr)
|
||||||
|
|
||||||
|
const subParts: string[] = []
|
||||||
|
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`)
|
||||||
|
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`)
|
||||||
|
const subline = subParts.length === 2
|
||||||
|
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
|
||||||
|
: subParts.join('')
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="trek-journey-popup-title">${primary}</div>
|
||||||
|
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
|
||||||
|
`
|
||||||
|
// Marker is bottom-anchored with a visible height of 36px (1.2× on
|
||||||
|
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
|
||||||
|
const offset: [number, number] = [0, -46]
|
||||||
|
if (popupRef.current) {
|
||||||
|
popupRef.current.setLngLat([item.lng, item.lat])
|
||||||
|
popupRef.current.setHTML(html)
|
||||||
|
popupRef.current.setOffset(offset)
|
||||||
|
const el = popupRef.current.getElement()
|
||||||
|
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||||||
|
} else {
|
||||||
|
popupRef.current = new mapboxgl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
closeOnMove: false,
|
||||||
|
anchor: 'bottom',
|
||||||
|
offset,
|
||||||
|
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
|
||||||
|
maxWidth: '280px',
|
||||||
|
})
|
||||||
|
.setLngLat([item.lng, item.lat])
|
||||||
|
.setHTML(html)
|
||||||
|
.addTo(mapRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hidePopup = useCallback(() => {
|
||||||
|
if (popupRef.current) {
|
||||||
|
try { popupRef.current.remove() } catch { /* noop */ }
|
||||||
|
popupRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setMarkerStyle = useCallback((id: string, highlighted: boolean) => {
|
||||||
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
|
const marker = markersRef.current.get(id)
|
||||||
|
if (!item || !marker) return
|
||||||
|
const el = marker.getElement()
|
||||||
|
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||||
|
if (!currentInner) return
|
||||||
|
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||||
|
// would wipe mapbox's positional transform and make the marker flicker.
|
||||||
|
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
|
||||||
|
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||||
|
currentInner.style.cssText = nextInner.style.cssText
|
||||||
|
currentInner.innerHTML = nextInner.innerHTML
|
||||||
|
el.style.zIndex = highlighted ? '1000' : '0'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const highlightMarker = useCallback((id: string | null) => {
|
||||||
|
const prev = highlightedRef.current
|
||||||
|
highlightedRef.current = id
|
||||||
|
if (prev && prev !== id) setMarkerStyle(prev, false)
|
||||||
|
if (id) {
|
||||||
|
setMarkerStyle(id, true)
|
||||||
|
showPopup(id)
|
||||||
|
} else {
|
||||||
|
hidePopup()
|
||||||
|
}
|
||||||
|
}, [setMarkerStyle, showPopup, hidePopup])
|
||||||
|
|
||||||
|
const focusMarker = useCallback((id: string) => {
|
||||||
|
highlightMarker(id)
|
||||||
|
const marker = markersRef.current.get(id)
|
||||||
|
if (!marker || !mapRef.current) return
|
||||||
|
try {
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: marker.getLngLat(),
|
||||||
|
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||||
|
pitch: mapbox3d ? 45 : 0,
|
||||||
|
duration: 600,
|
||||||
|
})
|
||||||
|
} catch { /* map not yet ready */ }
|
||||||
|
}, [highlightMarker, mapbox3d])
|
||||||
|
|
||||||
|
const invalidateSize = useCallback(() => {
|
||||||
|
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [highlightMarker, focusMarker, invalidateSize])
|
||||||
|
|
||||||
|
// Build map once per style/token change. Markers and layers are rebuilt
|
||||||
|
// inside the same effect so they stay in sync with the active style.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !mapboxToken) return
|
||||||
|
mapboxgl.accessToken = mapboxToken
|
||||||
|
|
||||||
|
const items = buildItems(entries)
|
||||||
|
itemsRef.current = items
|
||||||
|
|
||||||
|
const bounds = new mapboxgl.LngLatBounds()
|
||||||
|
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
||||||
|
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||||
|
const hasPoints = items.length > 0 || stableTrail.length > 0
|
||||||
|
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style: mapboxStyle,
|
||||||
|
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||||
|
zoom: hasPoints ? 2 : 1,
|
||||||
|
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||||
|
attributionControl: true,
|
||||||
|
antialias: mapboxQuality,
|
||||||
|
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
if (mapbox3d) {
|
||||||
|
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||||
|
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||||
|
}
|
||||||
|
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
||||||
|
// stay pinned to their coordinates at every zoom and pitch.
|
||||||
|
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||||
|
try { map.setTerrain(null) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// route trail — dashed line connecting entries in time order
|
||||||
|
if (items.length > 1) {
|
||||||
|
const coords = items.map(i => [i.lng, i.lat])
|
||||||
|
if (map.getSource('journey-route')) (map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({
|
||||||
|
type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
map.addSource('journey-route', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString },
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'journey-route-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'journey-route',
|
||||||
|
paint: {
|
||||||
|
'line-color': darkRef.current ? '#71717A' : '#A1A1AA',
|
||||||
|
'line-width': 1.5,
|
||||||
|
'line-opacity': 0.5,
|
||||||
|
'line-dasharray': [2, 3],
|
||||||
|
},
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markers
|
||||||
|
items.forEach((item) => {
|
||||||
|
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||||
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||||
|
.setLngLat([item.lng, item.lat])
|
||||||
|
.addTo(map)
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onMarkerClickRef.current?.(item.id)
|
||||||
|
})
|
||||||
|
markersRef.current.set(item.id, marker)
|
||||||
|
})
|
||||||
|
|
||||||
|
// fit bounds to all points
|
||||||
|
if (hasPoints) {
|
||||||
|
const pb = paddingBottom || 50
|
||||||
|
try {
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
||||||
|
maxZoom: 16,
|
||||||
|
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
} catch { /* empty bounds */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
markersRef.current.forEach(m => m.remove())
|
||||||
|
markersRef.current.clear()
|
||||||
|
if (popupRef.current) {
|
||||||
|
try { popupRef.current.remove() } catch { /* noop */ }
|
||||||
|
popupRef.current = null
|
||||||
|
}
|
||||||
|
highlightedRef.current = null
|
||||||
|
try { map.remove() } catch { /* noop */ }
|
||||||
|
mapRef.current = null
|
||||||
|
}
|
||||||
|
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||||
|
|
||||||
|
// external activeMarkerId → highlight + flyTo
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeMarkerId || !mapRef.current) return
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
highlightMarker(activeMarkerId)
|
||||||
|
const marker = markersRef.current.get(activeMarkerId)
|
||||||
|
if (!marker || !mapRef.current) return
|
||||||
|
try {
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: marker.getLngLat(),
|
||||||
|
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||||
|
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||||
|
duration: 500,
|
||||||
|
})
|
||||||
|
} catch { /* map not ready */ }
|
||||||
|
}, 50)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
||||||
|
|
||||||
|
if (!mapboxToken) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
||||||
|
className="flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
No Mapbox access token configured.<br />
|
||||||
|
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||||
|
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default JourneyMapGL
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||||
|
import MarkdownToolbar from './MarkdownToolbar';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.selectionStart = selectionStart;
|
||||||
|
textarea.selectionEnd = selectionEnd;
|
||||||
|
textarea.focus = vi.fn();
|
||||||
|
textarea.setSelectionRange = vi.fn();
|
||||||
|
return { current: textarea } as React.RefObject<HTMLTextAreaElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MarkdownToolbar', () => {
|
||||||
|
let onUpdate: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
onUpdate = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||||
|
const ref = createTextareaRef();
|
||||||
|
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MDTOOLBAR-002: buttons have correct title labels', () => {
|
||||||
|
const ref = createTextareaRef();
|
||||||
|
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||||
|
expect(screen.getByTitle('Bold')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Italic')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Link')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Heading')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Quote')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('List')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Ordered')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Divider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MDTOOLBAR-003: bold button wraps selected text with **', () => {
|
||||||
|
const ref = createTextareaRef('hello world', 6, 11);
|
||||||
|
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||||
|
fireEvent.click(screen.getByTitle('Bold'));
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith('hello **world**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MDTOOLBAR-004: italic button wraps selected text with _', () => {
|
||||||
|
const ref = createTextareaRef('hello world', 6, 11);
|
||||||
|
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||||
|
fireEvent.click(screen.getByTitle('Italic'));
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith('hello _world_');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MDTOOLBAR-005: link button wraps selected text as markdown link', () => {
|
||||||
|
const ref = createTextareaRef('click me', 0, 8);
|
||||||
|
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||||
|
fireEvent.click(screen.getByTitle('Link'));
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith('[click me](url)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MDTOOLBAR-006: heading button inserts line prefix', () => {
|
||||||
|
const ref = createTextareaRef('my title', 0, 0);
|
||||||
|
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||||
|
fireEvent.click(screen.getByTitle('Heading'));
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith('## my title');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||||
|
onUpdate: (value: string) => void
|
||||||
|
dark?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string }
|
||||||
|
|
||||||
|
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
|
||||||
|
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
|
||||||
|
{ icon: Italic, label: 'Italic', action: { type: 'wrap', before: '_', after: '_' } },
|
||||||
|
{ icon: Heading2, label: 'Heading', action: { type: 'line', prefix: '## ' } },
|
||||||
|
{ icon: Quote, label: 'Quote', action: { type: 'line', prefix: '> ' } },
|
||||||
|
{ icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } },
|
||||||
|
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
|
||||||
|
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } },
|
||||||
|
{ icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
|
||||||
|
const apply = (action: FormatAction) => {
|
||||||
|
const ta = textareaRef.current
|
||||||
|
if (!ta) return
|
||||||
|
|
||||||
|
const start = ta.selectionStart
|
||||||
|
const end = ta.selectionEnd
|
||||||
|
const text = ta.value
|
||||||
|
const selected = text.slice(start, end)
|
||||||
|
|
||||||
|
let result: string
|
||||||
|
let cursorPos: number
|
||||||
|
|
||||||
|
if (action.type === 'wrap') {
|
||||||
|
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
|
||||||
|
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length
|
||||||
|
} else if (action.type === 'insert') {
|
||||||
|
result = text.slice(0, start) + action.text + text.slice(end)
|
||||||
|
cursorPos = start + action.text.length
|
||||||
|
} else {
|
||||||
|
// line prefix — find start of current line
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1
|
||||||
|
result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart)
|
||||||
|
cursorPos = start + action.prefix.length
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(result)
|
||||||
|
|
||||||
|
// restore cursor after React re-render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.focus()
|
||||||
|
ta.setSelectionRange(cursorPos, cursorPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 2, padding: '6px 4px',
|
||||||
|
borderBottom: `1px solid var(--journal-border)`,
|
||||||
|
overflowX: 'auto',
|
||||||
|
}}>
|
||||||
|
{ACTIONS.map(a => (
|
||||||
|
<button
|
||||||
|
key={a.label}
|
||||||
|
type="button"
|
||||||
|
title={a.label}
|
||||||
|
onClick={() => apply(a.action)}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: 6,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'none', border: 'none',
|
||||||
|
color: 'var(--journal-muted)', cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
|
>
|
||||||
|
<a.icon size={15} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||||
|
import { formatLocationName } from '../../utils/formatters'
|
||||||
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
|
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||||
|
amazing: Laugh,
|
||||||
|
good: Smile,
|
||||||
|
neutral: Meh,
|
||||||
|
rough: Frown,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOOD_COLORS: Record<string, string> = {
|
||||||
|
amazing: 'text-pink-500',
|
||||||
|
good: 'text-amber-500',
|
||||||
|
neutral: 'text-zinc-400',
|
||||||
|
rough: 'text-violet-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||||
|
sunny: Sun,
|
||||||
|
partly: CloudSun,
|
||||||
|
cloudy: Cloud,
|
||||||
|
rainy: CloudRain,
|
||||||
|
stormy: CloudLightning,
|
||||||
|
cold: Snowflake,
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoUrl(p: JourneyPhoto): string {
|
||||||
|
return `/api/photos/${p.photo_id}/thumbnail`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMarkdown(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/[#*_~`>\[\]()!|-]/g, '')
|
||||||
|
.replace(/\n+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||||
|
dayLabel: number
|
||||||
|
dayColor: string
|
||||||
|
isActive: boolean
|
||||||
|
onClick: () => void
|
||||||
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
|
||||||
|
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||||
|
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||||
|
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||||
|
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
|
||||||
|
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
|
||||||
|
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
|
||||||
|
|
||||||
|
const thumbSrc = firstPhoto
|
||||||
|
? publicPhotoUrl
|
||||||
|
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
|
||||||
|
: photoUrl(firstPhoto as JourneyPhoto)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||||
|
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
|
||||||
|
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
|
||||||
|
isActive
|
||||||
|
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
|
||||||
|
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
|
||||||
|
} backdrop-blur-lg`}
|
||||||
|
>
|
||||||
|
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
|
||||||
|
{/* Photo thumbnail */}
|
||||||
|
{thumbSrc ? (
|
||||||
|
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
|
||||||
|
<img
|
||||||
|
src={thumbSrc}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{hasPhotos && entry.photos!.length > 1 && (
|
||||||
|
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
|
||||||
|
<Camera size={10} />
|
||||||
|
{entry.photos!.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
|
||||||
|
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||||
|
{/* Day number + date + mood/weather */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
|
||||||
|
{dayLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||||
|
{entry.entry_time && (
|
||||||
|
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
|
||||||
|
{MoodIcon && (
|
||||||
|
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
|
||||||
|
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
|
||||||
|
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
|
||||||
|
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
|
||||||
|
'bg-zinc-100 dark:bg-zinc-700'
|
||||||
|
}`}>
|
||||||
|
<MoodIcon size={11} className={moodColor} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{WeatherIcon && (
|
||||||
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||||
|
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
|
||||||
|
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Story preview (1-2 lines, only on active card) */}
|
||||||
|
{isActive && storyPreview && (
|
||||||
|
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
|
||||||
|
{storyPreview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location badge */}
|
||||||
|
<div className="flex items-center gap-1 mt-auto">
|
||||||
|
{hasLocation ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||||
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
|
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
X, Pencil, Trash2, MapPin, Clock, Camera,
|
||||||
|
Laugh, Smile, Meh, Frown,
|
||||||
|
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||||
|
ThumbsUp, ThumbsDown, ChevronDown,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import JournalBody from './JournalBody'
|
||||||
|
import { formatLocationName } from '../../utils/formatters'
|
||||||
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
|
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||||
|
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||||
|
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||||
|
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||||
|
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||||
|
sunny: { icon: Sun, label: 'Sunny' },
|
||||||
|
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||||
|
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||||
|
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||||
|
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||||
|
cold: { icon: Snowflake, label: 'Cold' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
|
||||||
|
if (builder) return builder(p.photo_id)
|
||||||
|
return `/api/photos/${p.photo_id}/${size}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entry: JourneyEntry
|
||||||
|
readOnly?: boolean
|
||||||
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
|
onClose: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||||
|
const photos = entry.photos || []
|
||||||
|
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||||
|
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||||
|
const prosArr = entry.pros_cons?.pros ?? []
|
||||||
|
const consArr = entry.pros_cons?.cons ?? []
|
||||||
|
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||||
|
|
||||||
|
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||||
|
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); onEdit(); }}
|
||||||
|
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); onDelete(); }}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||||
|
|
||||||
|
{/* Hero photo(s) */}
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||||
|
alt=""
|
||||||
|
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||||
|
onClick={() => onPhotoClick(photos, 0)}
|
||||||
|
/>
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||||
|
<Camera size={12} />
|
||||||
|
{photos.length} photos
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Photo strip for multiple photos */}
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
|
||||||
|
{photos.map((p, i) => (
|
||||||
|
<img
|
||||||
|
key={p.id || i}
|
||||||
|
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||||
|
alt=""
|
||||||
|
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||||
|
onClick={() => onPhotoClick(photos, i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-5 py-5 pb-32">
|
||||||
|
|
||||||
|
{/* Date + time + location header */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
|
||||||
|
{entry.entry_time && (
|
||||||
|
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
|
||||||
|
<Clock size={11} />
|
||||||
|
{entry.entry_time.slice(0, 5)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry.location_name && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||||
|
{formatLocationName(entry.location_name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{entry.title && (
|
||||||
|
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
|
||||||
|
{entry.title}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mood + Weather chips */}
|
||||||
|
{(mood || weather) && (
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
{mood && (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
|
||||||
|
<mood.icon size={13} />
|
||||||
|
{mood.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{weather && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||||
|
<weather.icon size={13} />
|
||||||
|
{weather.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Story */}
|
||||||
|
{entry.story && (
|
||||||
|
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
|
||||||
|
<JournalBody text={entry.story} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{entry.tags && entry.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||||
|
{entry.tags.map((tag, i) => (
|
||||||
|
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pros & Cons */}
|
||||||
|
{hasProscons && (
|
||||||
|
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
|
||||||
|
{prosArr.length > 0 && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
|
||||||
|
<ThumbsUp size={12} /> Pros
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{prosArr.map((p, i) => (
|
||||||
|
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||||
|
<span className="text-emerald-500 mt-0.5">+</span> {p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prosArr.length > 0 && consArr.length > 0 && (
|
||||||
|
<div className="border-t border-zinc-200 dark:border-zinc-700" />
|
||||||
|
)}
|
||||||
|
{consArr.length > 0 && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
|
||||||
|
<ThumbsDown size={12} /> Cons
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{consArr.map((c, i) => (
|
||||||
|
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||||
|
<span className="text-red-500 mt-0.5">−</span> {c}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import JourneyMap from './JourneyMap'
|
||||||
|
import MobileEntryCard from './MobileEntryCard'
|
||||||
|
import type { JourneyMapHandle } from './JourneyMap'
|
||||||
|
import type { JourneyEntry } from '../../store/journeyStore'
|
||||||
|
import { DAY_COLORS } from './dayColors'
|
||||||
|
|
||||||
|
interface MapEntry {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
title?: string | null
|
||||||
|
mood?: string | null
|
||||||
|
entry_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entries: JourneyEntry[] | any[]
|
||||||
|
mapEntries: MapEntry[]
|
||||||
|
trail?: { lat: number; lng: number }[]
|
||||||
|
dark?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
|
onEntryClick: (entry: any) => void
|
||||||
|
onAddEntry?: () => void
|
||||||
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
|
carouselBottom?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileMapTimeline({
|
||||||
|
entries,
|
||||||
|
mapEntries,
|
||||||
|
trail,
|
||||||
|
dark,
|
||||||
|
readOnly,
|
||||||
|
onEntryClick,
|
||||||
|
onAddEntry,
|
||||||
|
publicPhotoUrl,
|
||||||
|
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||||
|
}: Props) {
|
||||||
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
|
const carouselRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
|
||||||
|
const entryDayMeta = useMemo(() => {
|
||||||
|
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
|
||||||
|
const counters = new Map<string, number>()
|
||||||
|
return entries.map((e: any) => {
|
||||||
|
const dayIdx = uniqueDates.indexOf(e.entry_date)
|
||||||
|
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
|
||||||
|
counters.set(e.entry_date, dayLabel)
|
||||||
|
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
|
||||||
|
})
|
||||||
|
}, [entries])
|
||||||
|
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||||
|
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||||
|
const syncMapToCarousel = useCallback((index: number) => {
|
||||||
|
const entry = entries[index]
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
|
||||||
|
if (mapEntry) {
|
||||||
|
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
|
||||||
|
} else {
|
||||||
|
try { mapRef.current?.highlightMarker(null) } catch {}
|
||||||
|
}
|
||||||
|
}, [entries, mapEntries])
|
||||||
|
|
||||||
|
// Pick the card that's currently closest to the carousel horizontal center.
|
||||||
|
// More stable than IntersectionObserver thresholds when the active card can
|
||||||
|
// drift toward the viewport edge with proximity snapping.
|
||||||
|
const pickNearestCard = useCallback(() => {
|
||||||
|
const el = carouselRef.current
|
||||||
|
if (!el) return
|
||||||
|
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
|
||||||
|
let bestIdx = 0
|
||||||
|
let bestDist = Infinity
|
||||||
|
cardRefs.current.forEach((node, idx) => {
|
||||||
|
const r = node.getBoundingClientRect()
|
||||||
|
const cardCenter = r.left + r.width / 2
|
||||||
|
const d = Math.abs(cardCenter - containerCenter)
|
||||||
|
if (d < bestDist) { bestDist = d; bestIdx = idx }
|
||||||
|
})
|
||||||
|
setActiveIndex(prev => {
|
||||||
|
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
|
||||||
|
return bestIdx
|
||||||
|
})
|
||||||
|
}, [syncMapToCarousel])
|
||||||
|
|
||||||
|
// Defer all state updates until scrolling settles — updating activeIndex
|
||||||
|
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = carouselRef.current
|
||||||
|
if (!el || entries.length === 0) return
|
||||||
|
let settleTimer: number | null = null
|
||||||
|
const onScroll = () => {
|
||||||
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
|
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||||
|
}
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', onScroll)
|
||||||
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
|
}
|
||||||
|
}, [entries.length, pickNearestCard])
|
||||||
|
|
||||||
|
// Scroll a given card into the horizontal center of the carousel
|
||||||
|
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||||
|
const card = cardRefs.current.get(idx)
|
||||||
|
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Scroll carousel to entry when map marker is clicked
|
||||||
|
const handleMarkerClick = useCallback((id: string) => {
|
||||||
|
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||||
|
if (idx === -1) return
|
||||||
|
setActiveIndex(idx)
|
||||||
|
scrollCardIntoCenter(idx)
|
||||||
|
}, [entries, scrollCardIntoCenter])
|
||||||
|
|
||||||
|
// Tap on a card: if it's already active, open the edit view; otherwise
|
||||||
|
// activate + center it first (don't jump straight into the editor).
|
||||||
|
const handleCardTap = useCallback((entry: any, idx: number) => {
|
||||||
|
if (idx === activeIndex) {
|
||||||
|
onEntryClick(entry)
|
||||||
|
} else {
|
||||||
|
setActiveIndex(idx)
|
||||||
|
scrollCardIntoCenter(idx)
|
||||||
|
}
|
||||||
|
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
|
||||||
|
|
||||||
|
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||||
|
useEffect(() => {
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const timer = setTimeout(() => syncMapToCarousel(0), 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [entries.length])
|
||||||
|
|
||||||
|
const activeEntryId = entries[activeIndex]
|
||||||
|
? String(entries[activeIndex].id)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed left-0 right-0 z-10"
|
||||||
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
|
>
|
||||||
|
<JourneyMap
|
||||||
|
ref={mapRef}
|
||||||
|
entries={mapEntries}
|
||||||
|
checkins={[]}
|
||||||
|
trail={trail}
|
||||||
|
height={9999}
|
||||||
|
dark={dark}
|
||||||
|
onMarkerClick={handleMarkerClick}
|
||||||
|
fullScreen
|
||||||
|
/>
|
||||||
|
{!readOnly && onAddEntry && (
|
||||||
|
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||||
|
<button
|
||||||
|
onClick={onAddEntry}
|
||||||
|
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed left-0 right-0 z-10"
|
||||||
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
|
>
|
||||||
|
{/* Full-screen map */}
|
||||||
|
<JourneyMap
|
||||||
|
ref={mapRef}
|
||||||
|
entries={mapEntries}
|
||||||
|
checkins={[]}
|
||||||
|
trail={trail}
|
||||||
|
height={9999}
|
||||||
|
dark={dark}
|
||||||
|
activeMarkerId={activeEntryId}
|
||||||
|
onMarkerClick={handleMarkerClick}
|
||||||
|
fullScreen
|
||||||
|
paddingBottom={200}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom carousel */}
|
||||||
|
<div
|
||||||
|
className="fixed left-0 right-0 z-40"
|
||||||
|
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||||
|
style={{
|
||||||
|
scrollSnapType: 'x mandatory',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map((entry: any, i: number) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
data-idx={i}
|
||||||
|
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
||||||
|
style={{ scrollSnapAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<MobileEntryCard
|
||||||
|
entry={entry}
|
||||||
|
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||||
|
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||||
|
isActive={i === activeIndex}
|
||||||
|
onClick={() => handleCardTap(entry, i)}
|
||||||
|
publicPhotoUrl={publicPhotoUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAB: add entry — bottom right, above the timeline carousel */}
|
||||||
|
{!readOnly && onAddEntry && (
|
||||||
|
<div
|
||||||
|
className="fixed right-4 z-30"
|
||||||
|
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onAddEntry}
|
||||||
|
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-008
|
||||||
|
|
||||||
|
vi.mock('../../api/websocket', () => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
getSocketId: vi.fn(() => null),
|
||||||
|
setRefetchCallback: vi.fn(),
|
||||||
|
setPreReconnectHook: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import PhotoLightbox from './PhotoLightbox';
|
||||||
|
|
||||||
|
const samplePhotos = [
|
||||||
|
{ id: 'p1', src: '/photos/1.jpg', caption: 'Sunset at the beach' },
|
||||||
|
{ id: 'p2', src: '/photos/2.jpg', caption: 'Mountain trail' },
|
||||||
|
{ id: 'p3', src: '/photos/3.jpg', caption: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PhotoLightbox', () => {
|
||||||
|
it('FE-COMP-LIGHTBOX-001: renders without crashing when open', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||||
|
expect(document.body).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-LIGHTBOX-002: shows photo image', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute('src', '/photos/1.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-LIGHTBOX-003: shows close button', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
// Close button exists (the X button in the top bar)
|
||||||
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-LIGHTBOX-004: previous/next navigation works', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||||
|
// Initially shows photo 1
|
||||||
|
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img).toHaveAttribute('src', '/photos/1.jpg');
|
||||||
|
|
||||||
|
// Navigate to next photo via ArrowRight key
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||||
|
expect(screen.getByText('2 / 3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/2.jpg');
|
||||||
|
|
||||||
|
// Navigate back via ArrowLeft key
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowLeft' });
|
||||||
|
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/1.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-LIGHTBOX-005: keyboard Escape closes lightbox', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||||
|
fireEvent.keyDown(window, { key: 'Escape' });
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||||
|
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-LIGHTBOX-007: does not render when photos array is empty', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { container } = render(<PhotoLightbox photos={[]} onClose={onClose} />);
|
||||||
|
// Component returns null when photo is undefined (empty array, index 0 is undefined)
|
||||||
|
expect(container.querySelector('img')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-LIGHTBOX-008: calls onClose when close button clicked', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||||
|
// The close button is in the top bar — find the button and click it
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
// The first button in the top bar is the close (X) button
|
||||||
|
buttons[0].click();
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface LightboxPhoto {
|
||||||
|
id: string
|
||||||
|
src: string
|
||||||
|
caption?: string | null
|
||||||
|
provider?: string
|
||||||
|
asset_id?: string | null
|
||||||
|
owner_id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photos: LightboxPhoto[]
|
||||||
|
startIndex?: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
|
||||||
|
const [idx, setIdx] = useState(startIndex)
|
||||||
|
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
const photo = photos[idx]
|
||||||
|
const hasPrev = idx > 0
|
||||||
|
const hasNext = idx < photos.length - 1
|
||||||
|
|
||||||
|
const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev])
|
||||||
|
const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
if (e.key === 'ArrowLeft') prev()
|
||||||
|
if (e.key === 'ArrowRight') next()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [prev, next, onClose])
|
||||||
|
|
||||||
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
|
const t = e.touches[0]
|
||||||
|
touchStart.current = { x: t.clientX, y: t.clientY }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchEnd = (e: React.TouchEvent) => {
|
||||||
|
if (!touchStart.current) return
|
||||||
|
const t = e.changedTouches[0]
|
||||||
|
const dx = t.clientX - touchStart.current.x
|
||||||
|
const dy = t.clientY - touchStart.current.y
|
||||||
|
|
||||||
|
// swipe down to close
|
||||||
|
if (dy > 80 && Math.abs(dx) < 60) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// horizontal swipe
|
||||||
|
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||||
|
if (dx < 0) next()
|
||||||
|
else prev()
|
||||||
|
}
|
||||||
|
touchStart.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!photo) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 500,
|
||||||
|
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
paddingBottom: 'var(--bottom-nav-h)',
|
||||||
|
}}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Photo area — centered with nav overlays */}
|
||||||
|
<div
|
||||||
|
className="group/lightbox"
|
||||||
|
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{/* Top bar */}
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
|
||||||
|
{idx + 1} / {photos.length}
|
||||||
|
</span>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
|
||||||
|
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#fff', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
|
||||||
|
{hasPrev && (
|
||||||
|
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
|
||||||
|
position: 'absolute', left: 16, zIndex: 5,
|
||||||
|
width: 44, height: 44, borderRadius: '50%',
|
||||||
|
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#fff', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<ChevronLeft size={22} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo */}
|
||||||
|
<img
|
||||||
|
key={photo.id}
|
||||||
|
src={photo.src}
|
||||||
|
alt={photo.caption || ''}
|
||||||
|
style={{
|
||||||
|
maxWidth: '92vw', maxHeight: '92vh',
|
||||||
|
objectFit: 'contain', borderRadius: 4,
|
||||||
|
animation: 'fadeIn 0.15s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Next button */}
|
||||||
|
{hasNext && (
|
||||||
|
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
|
||||||
|
position: 'absolute', right: 16, zIndex: 5,
|
||||||
|
width: 44, height: 44, borderRadius: '50%',
|
||||||
|
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#fff', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<ChevronRight size={22} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Caption — bottom center overlay */}
|
||||||
|
{photo.caption && (
|
||||||
|
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
|
||||||
|
<p style={{
|
||||||
|
fontSize: 14, fontStyle: 'italic',
|
||||||
|
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
|
||||||
|
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
|
||||||
|
padding: '6px 14px', borderRadius: 10,
|
||||||
|
}}>{photo.caption}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export const DAY_COLORS = [
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#f97316', // orange
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#22c55e', // green
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#a855f7', // purple
|
||||||
|
'#ef4444', // red
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#84cc16', // lime
|
||||||
|
'#f43f5e', // rose
|
||||||
|
'#8b5cf6', // violet
|
||||||
|
'#10b981', // emerald
|
||||||
|
'#fb923c', // orange-400
|
||||||
|
'#60a5fa', // blue-400
|
||||||
|
'#c084fc', // purple-400
|
||||||
|
'#34d399', // emerald-400
|
||||||
|
'#fbbf24', // amber-400
|
||||||
|
'#e879f9', // fuchsia
|
||||||
|
'#4ade80', // green-400
|
||||||
|
'#f87171', // red-400
|
||||||
|
'#38bdf8', // sky-400
|
||||||
|
'#a3e635', // lime-400
|
||||||
|
'#fb7185', // rose-400
|
||||||
|
'#818cf8', // indigo-400
|
||||||
|
'#2dd4bf', // teal-400
|
||||||
|
'#facc15', // yellow
|
||||||
|
'#c026d3', // fuchsia-600
|
||||||
|
'#0ea5e9', // sky-500
|
||||||
|
]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// FE-COMP-MOOD-001 to FE-COMP-MOOD-005
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MOODS, WEATHERS, getMood, moodColor, tagColors, TAG_STYLES, MOOD_DEFAULT_COLOR } from './moodConfig';
|
||||||
|
|
||||||
|
describe('moodConfig', () => {
|
||||||
|
it('FE-COMP-MOOD-001: MOODS contains all five mood definitions', () => {
|
||||||
|
const ids = MOODS.map(m => m.id);
|
||||||
|
expect(ids).toEqual(['amazing', 'good', 'neutral', 'tired', 'rough']);
|
||||||
|
expect(MOODS).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MOOD-002: every mood has valid hex color and css var', () => {
|
||||||
|
for (const mood of MOODS) {
|
||||||
|
expect(mood.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
expect(mood.cssVar).toMatch(/^var\(--mood-.+\)$/);
|
||||||
|
expect(mood.icon).toBeDefined();
|
||||||
|
expect(mood.label).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MOOD-003: getMood returns correct mood or undefined', () => {
|
||||||
|
expect(getMood('amazing')?.id).toBe('amazing');
|
||||||
|
expect(getMood('rough')?.color).toBe('#9B8EC4');
|
||||||
|
expect(getMood('nonexistent')).toBeUndefined();
|
||||||
|
expect(getMood(null)).toBeUndefined();
|
||||||
|
expect(getMood(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MOOD-004: moodColor returns css var or fallback', () => {
|
||||||
|
expect(moodColor('good')).toBe('var(--mood-good)');
|
||||||
|
expect(moodColor(null)).toBe('var(--journal-faint)');
|
||||||
|
expect(moodColor('unknown')).toBe('var(--journal-faint)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MOOD-005: WEATHERS contains all eight entries with icons', () => {
|
||||||
|
expect(WEATHERS).toHaveLength(8);
|
||||||
|
const ids = WEATHERS.map(w => w.id);
|
||||||
|
expect(ids).toContain('sunny');
|
||||||
|
expect(ids).toContain('snowy');
|
||||||
|
expect(ids).toContain('stormy');
|
||||||
|
for (const w of WEATHERS) {
|
||||||
|
expect(w.icon).toBeDefined();
|
||||||
|
expect(w.label).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tagColors', () => {
|
||||||
|
it('FE-COMP-MOOD-006: returns known tag colors for light and dark mode', () => {
|
||||||
|
const light = tagColors('hidden gem', false);
|
||||||
|
expect(light.bg).toBe('#dcfce7');
|
||||||
|
expect(light.fg).toBe('#166534');
|
||||||
|
|
||||||
|
const dark = tagColors('hidden gem', true);
|
||||||
|
expect(dark.bg).toBe('rgba(22,101,52,0.2)');
|
||||||
|
expect(dark.fg).toBe('#86efac');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MOOD-007: returns fallback colors for unknown tags', () => {
|
||||||
|
const light = tagColors('random tag', false);
|
||||||
|
expect(light.bg).toBe('rgba(0,0,0,0.05)');
|
||||||
|
expect(light.fg).toBe('#374151');
|
||||||
|
|
||||||
|
const dark = tagColors('random tag', true);
|
||||||
|
expect(dark.bg).toBe('rgba(255,255,255,0.07)');
|
||||||
|
expect(dark.fg).toBe('#a1a1aa');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Sparkles, Sun, Minus, Moon, CloudRain, CloudSun, Cloud, CloudLightning, Snowflake, Thermometer, ThermometerSnowflake } from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface MoodDef {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
color: string
|
||||||
|
cssVar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOODS: MoodDef[] = [
|
||||||
|
{ id: 'amazing', label: 'Amazing', icon: Sparkles, color: '#E8654A', cssVar: 'var(--mood-amazing)' },
|
||||||
|
{ id: 'good', label: 'Good', icon: Sun, color: '#EF9F27', cssVar: 'var(--mood-good)' },
|
||||||
|
{ id: 'neutral', label: 'Neutral', icon: Minus, color: '#94928C', cssVar: 'var(--mood-neutral)' },
|
||||||
|
{ id: 'tired', label: 'Tired', icon: Moon, color: '#6B9BD2', cssVar: 'var(--mood-tired)' },
|
||||||
|
{ id: 'rough', label: 'Rough', icon: CloudRain,color: '#9B8EC4', cssVar: 'var(--mood-rough)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOOD_DEFAULT_COLOR = '#D4D4D4'
|
||||||
|
|
||||||
|
export function getMood(id: string | null | undefined): MoodDef | undefined {
|
||||||
|
if (!id) return undefined
|
||||||
|
return MOODS.find(m => m.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moodColor(id: string | null | undefined): string {
|
||||||
|
return getMood(id)?.cssVar || 'var(--journal-faint)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherDef {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEATHERS: WeatherDef[] = [
|
||||||
|
{ id: 'sunny', label: 'Sunny', icon: Sun },
|
||||||
|
{ id: 'partly', label: 'Partly cloudy', icon: CloudSun },
|
||||||
|
{ id: 'cloudy', label: 'Cloudy', icon: Cloud },
|
||||||
|
{ id: 'rainy', label: 'Rainy', icon: CloudRain },
|
||||||
|
{ id: 'stormy', label: 'Stormy', icon: CloudLightning },
|
||||||
|
{ id: 'snowy', label: 'Snowy', icon: Snowflake },
|
||||||
|
{ id: 'hot', label: 'Hot', icon: Thermometer },
|
||||||
|
{ id: 'cold', label: 'Cold', icon: ThermometerSnowflake },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getWeather(id: string | null | undefined): WeatherDef | undefined {
|
||||||
|
if (!id) return undefined
|
||||||
|
return WEATHERS.find(w => w.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TAG_STYLES: Record<string, { bg: string; fg: string; darkBg: string; darkFg: string }> = {
|
||||||
|
'hidden gem': { bg: '#dcfce7', fg: '#166534', darkBg: 'rgba(22,101,52,0.2)', darkFg: '#86efac' },
|
||||||
|
'must revisit': { bg: '#dbeafe', fg: '#1e40af', darkBg: 'rgba(30,64,175,0.2)', darkFg: '#93c5fd' },
|
||||||
|
'best meal': { bg: '#fef3c7', fg: '#92400e', darkBg: 'rgba(146,64,14,0.2)', darkFg: '#fcd34d' },
|
||||||
|
'tourist trap': { bg: '#fee2e2', fg: '#991b1b', darkBg: 'rgba(153,27,27,0.2)', darkFg: '#fca5a5' },
|
||||||
|
'disaster': { bg: '#fce4ec', fg: '#880e4f', darkBg: 'rgba(136,14,79,0.2)', darkFg: '#f48fb1' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagColors(tag: string, dark: boolean) {
|
||||||
|
const known = TAG_STYLES[tag.toLowerCase()]
|
||||||
|
if (known) return { bg: dark ? known.darkBg : known.bg, fg: dark ? known.darkFg : known.fg }
|
||||||
|
return { bg: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)', fg: dark ? '#a1a1aa' : '#374151' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// FE-UTIL-STRIPMD-001 to FE-UTIL-STRIPMD-006
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { stripMarkdown } from './stripMarkdown';
|
||||||
|
|
||||||
|
describe('stripMarkdown', () => {
|
||||||
|
it('FE-UTIL-STRIPMD-001: strips bold and italic formatting', () => {
|
||||||
|
expect(stripMarkdown('**bold** and _italic_')).toBe('bold and italic');
|
||||||
|
expect(stripMarkdown('__also bold__ and *also italic*')).toBe('also bold and also italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-UTIL-STRIPMD-002: strips headings', () => {
|
||||||
|
expect(stripMarkdown('# Heading 1')).toBe('Heading 1');
|
||||||
|
expect(stripMarkdown('## Heading 2')).toBe('Heading 2');
|
||||||
|
expect(stripMarkdown('### Heading 3')).toBe('Heading 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-UTIL-STRIPMD-003: converts links to text and removes images', () => {
|
||||||
|
expect(stripMarkdown('[click here](https://example.com)')).toBe('click here');
|
||||||
|
expect(stripMarkdown('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-UTIL-STRIPMD-004: strips code blocks and inline code', () => {
|
||||||
|
expect(stripMarkdown('use `console.log`')).toBe('use console.log');
|
||||||
|
expect(stripMarkdown('```\ncode block\n```')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-UTIL-STRIPMD-005: strips blockquotes and lists', () => {
|
||||||
|
expect(stripMarkdown('> quoted text')).toBe('quoted text');
|
||||||
|
expect(stripMarkdown('- item one')).toBe('item one');
|
||||||
|
expect(stripMarkdown('1. first item')).toBe('first item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-UTIL-STRIPMD-006: strips strikethrough and horizontal rules', () => {
|
||||||
|
expect(stripMarkdown('~~deleted~~')).toBe('deleted');
|
||||||
|
expect(stripMarkdown('---')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Strip markdown formatting to get plain text for previews.
|
||||||
|
* Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr.
|
||||||
|
*/
|
||||||
|
export function stripMarkdown(md: string): string {
|
||||||
|
return md
|
||||||
|
.replace(/^#{1,6}\s+/gm, '') // headings
|
||||||
|
.replace(/!\[.*?\]\(.*?\)/g, '') // images
|
||||||
|
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
|
||||||
|
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
|
||||||
|
.replace(/`([^`]+)`/g, '$1') // inline code
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '$1') // bold **
|
||||||
|
.replace(/__(.+?)__/g, '$1') // bold __
|
||||||
|
.replace(/\*(.+?)\*/g, '$1') // italic *
|
||||||
|
.replace(/_(.+?)_/g, '$1') // italic _
|
||||||
|
.replace(/~~(.+?)~~/g, '$1') // strikethrough
|
||||||
|
.replace(/^>\s?/gm, '') // blockquotes
|
||||||
|
.replace(/^[-*+]\s+/gm, '') // unordered lists
|
||||||
|
.replace(/^\d+\.\s+/gm, '') // ordered lists
|
||||||
|
.replace(/^---+$/gm, '') // horizontal rules
|
||||||
|
.replace(/\n{2,}/g, ' ') // collapse multiple newlines
|
||||||
|
.replace(/\n/g, ' ') // remaining newlines → spaces
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009
|
||||||
|
|
||||||
|
vi.mock('../../api/websocket', () => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
getSocketId: vi.fn(() => null),
|
||||||
|
setRefetchCallback: vi.fn(),
|
||||||
|
setPreReconnectHook: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { buildUser } from '../../../tests/helpers/factories';
|
||||||
|
import BottomNav from './BottomNav';
|
||||||
|
|
||||||
|
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
mockNavigate.mockClear();
|
||||||
|
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BottomNav', () => {
|
||||||
|
it('FE-COMP-BOTTOMNAV-001: renders without crashing', () => {
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(document.body).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(screen.getByText('Trips')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(screen.getByText('Profile')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BottomNav />);
|
||||||
|
await user.click(screen.getByText('Profile'));
|
||||||
|
// Profile sheet shows username
|
||||||
|
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BottomNav />);
|
||||||
|
await user.click(screen.getByText('Profile'));
|
||||||
|
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BottomNav />);
|
||||||
|
await user.click(screen.getByText('Profile'));
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BottomNav />);
|
||||||
|
await user.click(screen.getByText('Profile'));
|
||||||
|
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => {
|
||||||
|
const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' });
|
||||||
|
seedStore(useAuthStore, { user: adminUser, isAuthenticated: true });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BottomNav />);
|
||||||
|
await user.click(screen.getByText('Profile'));
|
||||||
|
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BottomNav />);
|
||||||
|
await user.click(screen.getByText('Profile'));
|
||||||
|
// Sheet is open — username visible
|
||||||
|
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||||
|
// The outermost fixed div is the backdrop wrapper, clicking it triggers onClose
|
||||||
|
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
|
||||||
|
expect(backdrop).toBeTruthy();
|
||||||
|
fireEvent.click(backdrop);
|
||||||
|
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
|
||||||
|
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
|
||||||
|
{ to: '/trips', label: 'Trips', icon: Plane },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
|
||||||
|
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
|
||||||
|
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
|
||||||
|
journey: { to: '/journey', label: 'Journey', icon: Compass },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BottomNav() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const darkMode = useSettingsStore(s => s.settings.dark_mode)
|
||||||
|
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
const addons = useAddonStore(s => s.addons)
|
||||||
|
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||||
|
const [showProfile, setShowProfile] = useState(false)
|
||||||
|
|
||||||
|
const items = [...BASE_ITEMS]
|
||||||
|
for (const addon of globalAddons) {
|
||||||
|
const nav = ADDON_NAV[addon.id]
|
||||||
|
if (nav) items.push(nav)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav
|
||||||
|
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
|
background: dark ? 'rgba(9,9,11,0.96)' : 'rgba(255,255,255,0.96)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
|
||||||
|
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={22} strokeWidth={2} />
|
||||||
|
<span className="text-[10px] font-medium">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowProfile(true)}
|
||||||
|
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
|
||||||
|
>
|
||||||
|
<User size={22} strokeWidth={2} />
|
||||||
|
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileSheet({ onClose }: { onClose: () => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleNav = (path: string) => {
|
||||||
|
onClose()
|
||||||
|
navigate(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
onClose()
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* Sheet */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
|
||||||
|
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Handle */}
|
||||||
|
<div className="flex justify-center pt-3 pb-2">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
<div className="px-6 pb-4 pt-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
|
||||||
|
{(user?.username || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
|
||||||
|
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
|
||||||
|
<Shield size={10} /> Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className="py-2 px-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleNav('/settings')}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={18} className="text-zinc-500" />
|
||||||
|
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleNav('/admin')}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Shield size={18} className="text-zinc-500" />
|
||||||
|
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<div className="py-2 px-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={18} className="text-red-500" />
|
||||||
|
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { act, fireEvent } from '@testing-library/react';
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import DemoBanner from './DemoBanner';
|
||||||
|
|
||||||
|
describe('DemoBanner', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-001
|
||||||
|
it('renders without crashing', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(document.body).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-002
|
||||||
|
it('overlay is visible on initial render with dismiss button', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-003
|
||||||
|
it('shows English welcome title by default', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-004
|
||||||
|
it('clicking "Got it" dismisses the banner', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<DemoBanner />);
|
||||||
|
const button = screen.getByText('Got it');
|
||||||
|
await user.click(button);
|
||||||
|
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-005
|
||||||
|
it('clicking the overlay backdrop dismisses the banner', () => {
|
||||||
|
const { container } = render(<DemoBanner />);
|
||||||
|
// The outermost fixed div is the overlay backdrop
|
||||||
|
const overlay = container.firstChild as HTMLElement;
|
||||||
|
fireEvent.click(overlay);
|
||||||
|
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-006
|
||||||
|
it('clicking the inner card does NOT dismiss', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<DemoBanner />);
|
||||||
|
// The inner card is the direct parent of the "Got it" button's container
|
||||||
|
const card = screen.getByText('Got it').closest('div[style*="background: white"]')!;
|
||||||
|
await user.click(card);
|
||||||
|
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-007
|
||||||
|
it('shows reset timer', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(screen.getByText(/Next reset in/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-008
|
||||||
|
it('shows upload-disabled notice', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(screen.getByText(/File uploads.*disabled in demo/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-009
|
||||||
|
it('shows "What is TREK?" section', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(screen.getByText('What is TREK?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-010
|
||||||
|
it('shows addon cards', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Atlas')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-011
|
||||||
|
it('shows full version features section', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(screen.getByText(/Additionally in the full version/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-COMP-DEMOBANNER-012
|
||||||
|
it('self-host link points to GitHub', () => {
|
||||||
|
render(<DemoBanner />);
|
||||||
|
const link = screen.getByText('self-host it').closest('a')!;
|
||||||
|
expect(link).toHaveAttribute('href', 'https://github.com/mauriceboe/TREK');
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timer update test
|
||||||
|
it('updates countdown timer after interval tick', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
||||||
|
// Set time to XX:30 so minutesLeft = 59 - 30 = 29
|
||||||
|
vi.setSystemTime(new Date(2026, 3, 7, 12, 30, 0));
|
||||||
|
render(<DemoBanner />);
|
||||||
|
expect(screen.getByText(/29 minutes/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Advance to XX:31 and tick the interval; wrap in act so React flushes state update
|
||||||
|
await act(async () => {
|
||||||
|
vi.setSystemTime(new Date(2026, 3, 7, 12, 31, 0));
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/28 minutes/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -118,6 +118,70 @@ const texts: Record<string, DemoTexts> = {
|
|||||||
selfHostLink: 'alójalo tú mismo',
|
selfHostLink: 'alójalo tú mismo',
|
||||||
close: 'Entendido',
|
close: 'Entendido',
|
||||||
},
|
},
|
||||||
|
zh: {
|
||||||
|
titleBefore: '欢迎来到 ',
|
||||||
|
titleAfter: '',
|
||||||
|
title: '欢迎来到 TREK 演示版',
|
||||||
|
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
|
||||||
|
resetIn: '下次重置将在',
|
||||||
|
minutes: '分钟后',
|
||||||
|
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
|
||||||
|
fullVersionTitle: '完整版本还包括:',
|
||||||
|
features: [
|
||||||
|
'文件上传(照片、文档、封面)',
|
||||||
|
'API 密钥管理(Google Maps、天气)',
|
||||||
|
'用户和权限管理',
|
||||||
|
'自动备份',
|
||||||
|
'附加组件管理(启用/禁用)',
|
||||||
|
'OIDC / SSO 单点登录',
|
||||||
|
],
|
||||||
|
addonsTitle: '模块化附加组件(完整版本可禁用)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', '带日历、节假日和用户融合的假期规划器'],
|
||||||
|
['Atlas', '带已访问国家和旅行统计的世界地图'],
|
||||||
|
['Packing', '按旅行管理清单'],
|
||||||
|
['Budget', '支持分摊的费用追踪'],
|
||||||
|
['Documents', '将文件附加到旅行'],
|
||||||
|
['Widgets', '货币换算和时区工具'],
|
||||||
|
],
|
||||||
|
whatIs: '什么是 TREK?',
|
||||||
|
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
|
||||||
|
selfHost: '开源项目 - ',
|
||||||
|
selfHostLink: '自行部署',
|
||||||
|
close: '知道了',
|
||||||
|
},
|
||||||
|
'zh-TW': {
|
||||||
|
titleBefore: '歡迎來到 ',
|
||||||
|
titleAfter: '',
|
||||||
|
title: '歡迎來到 TREK 展示版',
|
||||||
|
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
|
||||||
|
resetIn: '下次重設將在',
|
||||||
|
minutes: '分鐘後',
|
||||||
|
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
|
||||||
|
fullVersionTitle: '完整版本還包含:',
|
||||||
|
features: [
|
||||||
|
'檔案上傳(照片、文件、封面)',
|
||||||
|
'API 金鑰管理(Google Maps、天氣)',
|
||||||
|
'使用者與權限管理',
|
||||||
|
'自動備份',
|
||||||
|
'附加元件管理(啟用/停用)',
|
||||||
|
'OIDC / SSO 單一登入',
|
||||||
|
],
|
||||||
|
addonsTitle: '模組化附加元件(完整版本可停用)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
|
||||||
|
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
|
||||||
|
['Packing', '依行程管理的檢查清單'],
|
||||||
|
['Budget', '支援分攤的費用追蹤'],
|
||||||
|
['Documents', '將檔案附加到行程'],
|
||||||
|
['Widgets', '貨幣換算與時區工具'],
|
||||||
|
],
|
||||||
|
whatIs: 'TREK 是什麼?',
|
||||||
|
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
|
||||||
|
selfHost: '開源專案 - ',
|
||||||
|
selfHostLink: '自行架設',
|
||||||
|
close: '知道了',
|
||||||
|
},
|
||||||
ar: {
|
ar: {
|
||||||
titleBefore: 'مرحبًا بك في ',
|
titleBefore: 'مرحبًا بك في ',
|
||||||
titleAfter: '',
|
titleAfter: '',
|
||||||
@@ -150,6 +214,38 @@ const texts: Record<string, DemoTexts> = {
|
|||||||
selfHostLink: 'استضفه بنفسك',
|
selfHostLink: 'استضفه بنفسك',
|
||||||
close: 'فهمت',
|
close: 'فهمت',
|
||||||
},
|
},
|
||||||
|
id: {
|
||||||
|
titleBefore: 'Selamat datang di ',
|
||||||
|
titleAfter: '',
|
||||||
|
title: 'Selamat datang di Demo TREK',
|
||||||
|
description: 'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
|
||||||
|
resetIn: 'Atur ulang berikutnya dalam',
|
||||||
|
minutes: 'menit',
|
||||||
|
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
|
||||||
|
fullVersionTitle: 'Selain itu dalam versi lengkap:',
|
||||||
|
features: [
|
||||||
|
'Unggah file (foto, dokumen, sampul)',
|
||||||
|
'Manajemen kunci API (Google Maps, Cuaca)',
|
||||||
|
'Manajemen pengguna & izin',
|
||||||
|
'Pencadangan otomatis',
|
||||||
|
'Manajemen Addon (aktifkan/nonaktifkan)',
|
||||||
|
'OIDC / SSO single sign-on',
|
||||||
|
],
|
||||||
|
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
|
||||||
|
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
|
||||||
|
['Pengepakan', 'Daftar periksa per perjalanan'],
|
||||||
|
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
|
||||||
|
['Dokumen', 'Lampirkan file ke perjalanan'],
|
||||||
|
['Widget', 'Konverter mata uang & zona waktu'],
|
||||||
|
],
|
||||||
|
whatIs: 'Apa itu TREK?',
|
||||||
|
whatIsDesc: 'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
|
||||||
|
selfHost: 'Buka sumber — ',
|
||||||
|
selfHostLink: 'host mandiri',
|
||||||
|
close: 'Mengerti',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user