Compare commits
1162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86b476f011 | |||
| 959d6c3714 | |||
| c37ee2c6c3 | |||
| 0175a06c9e | |||
| 39113e12de | |||
| d02ecf239e | |||
| 8691814330 | |||
| 48098ef5ec | |||
| c565f22bf2 | |||
| 5bf8dd8cef | |||
| 20791a29a7 | |||
| 6d2dd37414 | |||
| 0d2657ee37 | |||
| 0a8fb1f53b | |||
| 2fe6657edd | |||
| 5f964b9524 | |||
| 8bda980028 | |||
| 831a4fd478 | |||
| 4ff4435f8b | |||
| 69b699c9bf | |||
| 98032fda0c | |||
| e04ceeb1ee | |||
| e5000ff7dd | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 | |||
| 51b5bd6966 | |||
| 6072b969d6 | |||
| 4ae4e0c676 | |||
| 51ab30f436 | |||
| 8b53948231 | |||
| 78d6f2ba77 | |||
| bb89d70a94 | |||
| ad9f3887d8 | |||
| 7f1fb508db | |||
| 1f5deeba6c | |||
| ca832e8d88 | |||
| 12fc7f7b68 | |||
| 2770a189df | |||
| 2b162a8cc7 | |||
| 009d89fecf | |||
| 5c3b89578d | |||
| 303e7de433 | |||
| 08eb7f3733 | |||
| 90d86eda61 | |||
| 0eca6d54a1 | |||
| bc1fb71391 | |||
| cb425fb397 | |||
| 35ed712d46 | |||
| 4923973380 | |||
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 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 | |||
| d2efd960b5 | |||
| c51a27371b | |||
| 252d2d22a8 | |||
| 80c2486570 | |||
| 7dcd89fb71 | |||
| 8458481950 | |||
| 808b7f7a72 | |||
| f4ee7b868d | |||
| e99960c3b6 | |||
| c39d242cfb | |||
| 2f8a189319 | |||
| 44138af11a | |||
| bc6c59f358 | |||
| 54804d0e5f | |||
| 631e47944b | |||
| 3abcc0ec76 | |||
| 530f233b7d | |||
| fbb3bb862c | |||
| 3c3b7b9136 | |||
| 99514ddce1 | |||
| b0ffb63d67 | |||
| d909aac751 | |||
| e91b79ebfc | |||
| 2d7babcba3 | |||
| e56ea068ef | |||
| a091051387 | |||
| df3e62af5c | |||
| 399e4acf03 | |||
| e0fd9830d9 | |||
| 7a445583d7 | |||
| 1d9d628e2d | |||
| 005c08dcea | |||
| e25fec4e4a | |||
| 85e69b8a3d | |||
| 1d57eacfa4 | |||
| ecf7433980 | |||
| 433d780f74 | |||
| 27f8856e9b | |||
| f2c90ee0f4 | |||
| 83d256ebac | |||
| 3c4f5f7193 | |||
| 31124a604a | |||
| 0d9dbb6286 | |||
| 66ae577b7b | |||
| 706548c45d | |||
| aa32df5ee1 | |||
| 1f9ae8e4b5 | |||
| d69585a820 | |||
| 723f8a1c3d | |||
| 678fe2d12c | |||
| e97ecd558f | |||
| 3d33191925 | |||
| 48e1b732d8 | |||
| d50c84b755 | |||
| fcbfeb6793 | |||
| 77f2c616de | |||
| 9f8d3f8d99 | |||
| 3f26a68f64 | |||
| a3b6a89471 | |||
| ee54d89144 | |||
| e78c2a97bd | |||
| 5940b7f24e | |||
| 1c3a1ba8da | |||
| b6d927a3d6 | |||
| c5e41f2228 |
@@ -2,9 +2,36 @@ node_modules
|
|||||||
client/node_modules
|
client/node_modules
|
||||||
server/node_modules
|
server/node_modules
|
||||||
client/dist
|
client/dist
|
||||||
|
shared/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,111 @@
|
|||||||
|
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
|
||||||
|
- Proxmox Community Script
|
||||||
|
- 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` *(wiki-only PRs are exempt)*
|
||||||
|
- [ ] 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,93 @@
|
|||||||
|
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 hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
|
||||||
|
if (hasBypass) continue;
|
||||||
|
|
||||||
|
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||||
|
if (!hasLabel) continue;
|
||||||
|
|
||||||
|
// Wiki-only PRs are exempt — clear label and skip
|
||||||
|
const files = [];
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const { data } = await github.rest.pulls.listFiles({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: pull.number,
|
||||||
|
per_page: 100,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
files.push(...data);
|
||||||
|
if (data.length < 100) break;
|
||||||
|
}
|
||||||
|
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||||
|
if (allWiki) {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pull.number,
|
||||||
|
name: 'wrong-base-branch',
|
||||||
|
});
|
||||||
|
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,13 +3,136 @@ name: Build & Push Docker Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.github/workflows/**'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
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:
|
||||||
build:
|
version-bump:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.bump.outputs.VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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 all workspace + root package.json files and the root lockfile in one shot
|
||||||
|
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||||
|
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 package.json package-lock.json server/package.json client/package.json shared/package.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:
|
||||||
|
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: main
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -18,8 +141,79 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
platforms: ${{ matrix.platform }}
|
||||||
tags: mauriceboe/nomad:latest
|
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: main
|
||||||
|
|
||||||
|
- 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)"
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t "mauriceboe/trek:latest" \
|
||||||
|
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||||
|
-t "mauriceboe/trek:$VERSION" \
|
||||||
|
"${digests[@]}"
|
||||||
|
|
||||||
|
- name: Inspect manifest
|
||||||
|
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,137 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// bypass-branch-check label skips all enforcement
|
||||||
|
if (labels.includes('bypass-branch-check')) {
|
||||||
|
console.log('bypass-branch-check label present, skipping enforcement.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wiki-only PRs are exempt from branch enforcement
|
||||||
|
const files = [];
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const { data } = await github.rest.pulls.listFiles({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: prNumber,
|
||||||
|
per_page: 100,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
files.push(...data);
|
||||||
|
if (data.length < 100) break;
|
||||||
|
}
|
||||||
|
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||||
|
if (allWiki) {
|
||||||
|
console.log('All changed files are under wiki/ — skipping enforcement.');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,53 @@
|
|||||||
|
name: Lint & Prettier
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run lint & format check
|
||||||
|
id: checks
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
cd shared
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
|
||||||
|
- name: Comment on PR if checks failed
|
||||||
|
if: steps.checks.outcome == 'failure'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: [
|
||||||
|
'## ❌ Lint & Prettier check failed',
|
||||||
|
'',
|
||||||
|
'Please fix the issues locally by running the following commands inside the `shared` package:',
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
'cd shared',
|
||||||
|
'npm run lint',
|
||||||
|
'npm run format',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'Then commit and push the changes.',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
- name: Fail the job if checks failed
|
||||||
|
if: steps.checks.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scout:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: trek:scan
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/scout-action@v1
|
||||||
|
with:
|
||||||
|
command: cves
|
||||||
|
image: trek:scan
|
||||||
|
only-severities: critical,high
|
||||||
|
exit-code: true
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
paths:
|
||||||
|
- 'server/**'
|
||||||
|
- 'client/**'
|
||||||
|
- 'shared/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
shared-contracts:
|
||||||
|
name: Shared Contracts (Zod)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspace shared
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd shared && npm run typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd shared && npm test
|
||||||
|
|
||||||
|
server-tests:
|
||||||
|
name: Server Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||||
|
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||||
|
# optional native binary, so npm ci/install skips it on the runner.
|
||||||
|
# Install the matching version explicitly so the server's SWC transform
|
||||||
|
# (server/vitest.config.ts) can load.
|
||||||
|
run: |
|
||||||
|
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||||
|
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
|
- name: Build server (tsc -> dist)
|
||||||
|
run: cd server && npm run build
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd server && npm run typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: cd server && npm run lint:check
|
||||||
|
|
||||||
|
- 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: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspace shared && npm ci --workspace client
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd client && npm run typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: cd client && npm run lint:check
|
||||||
|
|
||||||
|
- name: Page pattern check
|
||||||
|
run: cd client && npm run lint:pages
|
||||||
|
|
||||||
|
- 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: clone
|
||||||
@@ -3,6 +3,10 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
shared/dist/
|
||||||
|
server/public/*
|
||||||
|
!server/public/.gitkeep
|
||||||
|
|
||||||
# Generated PWA icons (built from SVG via prebuild)
|
# Generated PWA icons (built from SVG via prebuild)
|
||||||
client/public/icons/*.png
|
client/public/icons/*.png
|
||||||
@@ -11,9 +15,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 +35,7 @@ Thumbs.db
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -52,3 +60,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`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||||
|
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.
|
||||||
@@ -1,39 +1,72 @@
|
|||||||
# Stage 1: React Client bauen
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:24-alpine AS shared-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app
|
||||||
COPY client/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
COPY shared/package.json ./shared/
|
||||||
COPY client/ ./
|
RUN npm ci --workspace=shared
|
||||||
RUN npm run build
|
COPY shared/ ./shared/
|
||||||
|
RUN npm run build --workspace=shared
|
||||||
|
|
||||||
# Stage 2: Produktions-Server
|
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
FROM node:24-alpine AS client-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY client/package.json ./client/
|
||||||
|
RUN npm ci --workspace=client
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY client/ ./client/
|
||||||
|
RUN npm run build --workspace=client
|
||||||
|
|
||||||
|
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||||
|
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||||
|
FROM node:24-alpine AS server-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci --workspace=server --ignore-scripts
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY server/ ./server/
|
||||||
|
RUN npm run build --workspace=server
|
||||||
|
|
||||||
|
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
# Workspace manifests only — source never enters this stage.
|
||||||
COPY server/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN apk add --no-cache python3 make g++ && \
|
COPY shared/package.json ./shared/
|
||||||
npm ci --production && \
|
COPY server/package.json ./server/
|
||||||
apk del python3 make g++
|
|
||||||
|
|
||||||
# Server-Code kopieren
|
# better-sqlite3 native addon requires build tools; purged after install.
|
||||||
COPY server/ ./
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
|
npm ci --workspace=server --omit=dev && \
|
||||||
|
apk del python3 make g++ && \
|
||||||
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
# Gebauten Client kopieren
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
|
COPY server/tsconfig.json ./server/
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
|
|
||||||
# Fonts für PDF-Export kopieren
|
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
ln -s /app/uploads /app/server/uploads && \
|
||||||
|
ln -s /app/data /app/server/data && \
|
||||||
|
chown -R node:node /app
|
||||||
|
|
||||||
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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", "--"]
|
||||||
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -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,161 +1,329 @@
|
|||||||
<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="NOMAD" height="60" />
|
|
||||||
</picture>
|
|
||||||
<br />
|
|
||||||
<em>Navigation Organizer for Maps, Activities & Destinations</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/nomad"><img src="https://img.shields.io/docker/pulls/mauriceboe/nomad" alt="Docker Pulls" /></a>
|
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||||
<a href="https://github.com/mauriceboe/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></a>
|
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||||
<a href="https://github.com/mauriceboe/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" 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 NOMAD 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.liketrek.com"><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 NOMAD 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 and German (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/nomad
|
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">
|
||||||
|
|
||||||
NOMAD 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 NOMAD 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. NOMAD 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/nomad:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: nomad
|
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/nomad
|
docker pull mauriceboe/trek
|
||||||
docker rm -f nomad
|
docker rm -f trek
|
||||||
docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
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 nomad --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 NOMAD 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:** NOMAD 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 NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy,
|
|||||||
```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,74 @@ 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 NOMAD: 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` |
|
||||||
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `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/NOMAD.git
|
|
||||||
cd NOMAD
|
<br />
|
||||||
docker build -t nomad .
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ Only the latest version receives security updates. Please update to the latest r
|
|||||||
If you discover a security vulnerability, please report it responsibly:
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
1. **Do not** open a public issue
|
1. **Do not** open a public issue
|
||||||
2. Email: **mauriceboe@icloud.com**
|
2. Email: **report@liketrek.com**
|
||||||
3. Include a description of the vulnerability and steps to reproduce
|
3. Include a description of the vulnerability and steps to reproduce
|
||||||
|
|
||||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This policy covers the NOMAD 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,121 @@
|
|||||||
|
# Trademark Policy
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This is the TREK project's policy for the use of our trademarks. While TREK is
|
||||||
|
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
|
||||||
|
license does not include a license to use our trademarks.
|
||||||
|
|
||||||
|
This policy describes how you may use our trademarks. Our goal is to strike a
|
||||||
|
balance between: 1) our need to ensure that our trademarks remain reliable
|
||||||
|
indicators of the software we release; and 2) our community members' desire to
|
||||||
|
be full participants in the TREK project.
|
||||||
|
|
||||||
|
## Our trademarks
|
||||||
|
|
||||||
|
This policy covers the name "TREK" as well as any associated logos, trade dress,
|
||||||
|
goodwill, or designs (our "Marks").
|
||||||
|
|
||||||
|
## In general
|
||||||
|
|
||||||
|
Whenever you use our Marks, you must always do so in a way that does not mislead
|
||||||
|
anyone about exactly who is the source of the software. For example, you cannot
|
||||||
|
say you are distributing TREK when you're distributing a modified version of it,
|
||||||
|
because people would think they would be getting the same software that they
|
||||||
|
can get directly from us when they aren't. You also cannot use our Marks on
|
||||||
|
your website in a way that suggests that your website is an official TREK
|
||||||
|
website or that we endorse your website. But, if true, you can say you like
|
||||||
|
TREK, that you participate in the TREK community, that you are providing an
|
||||||
|
unmodified version of TREK, or that you wrote a guide describing how to use
|
||||||
|
TREK.
|
||||||
|
|
||||||
|
This fundamental requirement, that it is always clear to people what they are
|
||||||
|
getting and from whom, is reflected throughout this policy. It should also
|
||||||
|
serve as your guide if you are not sure about how you are using the Marks.
|
||||||
|
|
||||||
|
In addition:
|
||||||
|
|
||||||
|
* You may not use or register, in whole or in part, the Marks as part of your
|
||||||
|
own trademark, service mark, domain name, company name, trade name, product
|
||||||
|
name or service name.
|
||||||
|
* Trademark law does not allow your use of names or trademarks that are too
|
||||||
|
similar to ours. You therefore may not use an obvious variation of any of our
|
||||||
|
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
|
||||||
|
abbreviation for a similar or compatible product or service.
|
||||||
|
* You agree that you will not acquire any rights in the Marks and that any
|
||||||
|
goodwill generated by your use of the Marks and participation in our
|
||||||
|
community inures solely to our benefit.
|
||||||
|
|
||||||
|
## Distribution of unmodified source code or unmodified executable code we have compiled
|
||||||
|
|
||||||
|
When you redistribute an unmodified copy of TREK, you are not changing the
|
||||||
|
quality or nature of it. Therefore, you may retain the Marks we have placed on
|
||||||
|
the software to identify your redistribution. This kind of use only applies if
|
||||||
|
you are redistributing an official TREK distribution that has not been changed
|
||||||
|
in any way.
|
||||||
|
|
||||||
|
## Distribution of executable code that you have compiled, or modified code
|
||||||
|
|
||||||
|
You may use the word mark "TREK", but not any TREK logos, to truthfully
|
||||||
|
describe the origin of the software that you are providing, that is, that the
|
||||||
|
code you are distributing is a modification of TREK. You may say, for example,
|
||||||
|
that "this software is derived from the source code for TREK."
|
||||||
|
|
||||||
|
Of course, you can place your own trademarks or logos on versions of the
|
||||||
|
software to which you have made substantive modifications, because by modifying
|
||||||
|
the software, you have become the origin of that exact version. In that case,
|
||||||
|
you should not use our Marks.
|
||||||
|
|
||||||
|
However, you may use our Marks for the distribution of code (source or
|
||||||
|
executable) on the condition that any executable is built from an official TREK
|
||||||
|
source code release and that any modifications are limited to switching on or
|
||||||
|
off features already included in the software, translations into other
|
||||||
|
languages, and incorporating minor bug-fix patches. Use of our Marks on any
|
||||||
|
further modification is not permitted.
|
||||||
|
|
||||||
|
## Mobile wrappers, hosted instances, and forks
|
||||||
|
|
||||||
|
The following clarifications apply specifically to common ways TREK is
|
||||||
|
redistributed:
|
||||||
|
|
||||||
|
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
|
||||||
|
as "a TREK instance" or "running TREK." You may not name the service itself
|
||||||
|
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
|
||||||
|
Official," etc.).
|
||||||
|
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
|
||||||
|
TREK.** You may describe your app as "a mobile client for TREK" or "for use
|
||||||
|
with TREK." You may not publish it on app stores under the name "TREK" or a
|
||||||
|
confusingly similar name, and you may not use the TREK logo as the app icon
|
||||||
|
unless your wrapper distributes only an unmodified, official TREK instance
|
||||||
|
and you have obtained permission.
|
||||||
|
* **Forks of the TREK source code.** Forks that diverge from upstream must use
|
||||||
|
a different name. You may state that your fork is "based on TREK" or "a fork
|
||||||
|
of TREK," but the project name itself must be your own.
|
||||||
|
|
||||||
|
## Statements about your software's relation to TREK
|
||||||
|
|
||||||
|
You may use the word mark, but not TREK logos, to truthfully describe the
|
||||||
|
relationship between your software and ours. The word mark "TREK" should be
|
||||||
|
used after a verb or preposition that describes the relationship between your
|
||||||
|
software and ours. So you may say, for example, "Bob's app for TREK" but may
|
||||||
|
not say "Bob's TREK app." Some other examples that may work for you are:
|
||||||
|
|
||||||
|
* [Your software] uses TREK
|
||||||
|
* [Your software] is powered by TREK
|
||||||
|
* [Your software] runs on TREK
|
||||||
|
* [Your software] for use with TREK
|
||||||
|
* [Your software] for TREK
|
||||||
|
|
||||||
|
## Questions and permission requests
|
||||||
|
|
||||||
|
If you are not sure whether your intended use of the Marks is permitted under
|
||||||
|
this policy, or if you would like to request explicit permission for a use that
|
||||||
|
is not covered, please open an issue on the TREK GitHub repository or contact
|
||||||
|
the maintainers directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
These guidelines are based on the
|
||||||
|
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
|
||||||
|
under a
|
||||||
|
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CLIENT_DIR="$REPO_ROOT/client"
|
||||||
|
SERVER_DIR="$REPO_ROOT/server"
|
||||||
|
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||||
|
|
||||||
|
echo "==> Installing client dependencies"
|
||||||
|
cd "$CLIENT_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Building client"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "==> Installing server dependencies"
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Populating server/public"
|
||||||
|
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||||
|
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||||
|
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||||
|
|
||||||
|
echo "==> Done — server/public is ready"
|
||||||
@@ -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.22
|
||||||
|
description: Minimal Helm chart for TREK app
|
||||||
|
appVersion: "3.0.22"
|
||||||
@@ -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,66 @@
|
|||||||
|
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.HSTS_INCLUDE_SUBDOMAINS }}
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | 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,119 @@
|
|||||||
|
|
||||||
|
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.
|
||||||
|
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
||||||
|
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Playwright E2E (FE7)
|
||||||
|
e2e/.tmp/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test as setup, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Relative to the config dir (client/), matching `storageState` in
|
||||||
|
// playwright.config.ts. Playwright runs from the client workspace root.
|
||||||
|
const stateFile = 'e2e/.tmp/state.json'
|
||||||
|
|
||||||
|
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
|
||||||
|
// seeded admin is created with must_change_password=1, so the first login goes
|
||||||
|
// through the forced change-password step before reaching the dashboard.
|
||||||
|
const EMAIL = 'e2e@trek.local'
|
||||||
|
const SEED_PW = 'E2eTest12345!'
|
||||||
|
const NEW_PW = 'E2eChanged12345!'
|
||||||
|
|
||||||
|
setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.locator('input[type="email"]').fill(EMAIL)
|
||||||
|
await page.locator('input[type="password"]').fill(SEED_PW)
|
||||||
|
await page.locator('button[type="submit"]').click()
|
||||||
|
|
||||||
|
// must_change_password=1 → the change-password step renders two password
|
||||||
|
// fields (new + confirm). Selector-agnostic of the UI language.
|
||||||
|
const pw = page.locator('input[type="password"]')
|
||||||
|
await expect(pw).toHaveCount(2)
|
||||||
|
await pw.nth(0).fill(NEW_PW)
|
||||||
|
await pw.nth(1).fill(NEW_PW)
|
||||||
|
await page.locator('button[type="submit"]').click()
|
||||||
|
|
||||||
|
await page.waitForURL('**/dashboard', { timeout: 30_000 })
|
||||||
|
|
||||||
|
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
|
||||||
|
// asynchronously (after the notices fetch), so wait for it before clicking.
|
||||||
|
// Dismissal is recorded server-side against this user, so clearing it here
|
||||||
|
// keeps it cleared for every authenticated flow in the run (shared test DB).
|
||||||
|
const ok = page.getByRole('button', { name: 'OK', exact: true })
|
||||||
|
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
|
||||||
|
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
|
||||||
|
await ok.click()
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.context().storageState({ path: stateFile })
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
|
||||||
|
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
|
||||||
|
// authenticated stack — dashboard → TripFormModal → POST /api/trips → store →
|
||||||
|
// re-render — against the real backend + isolated test DB.
|
||||||
|
test('create a trip and see it on the dashboard', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
|
||||||
|
// The "+ New Trip" card is always rendered in the default (planned) filter.
|
||||||
|
await page.locator('.add-trip-card').click()
|
||||||
|
|
||||||
|
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
|
||||||
|
// button (the primary action lives in the footer), so click it explicitly
|
||||||
|
// rather than pressing Enter. The Create button is the slate primary button;
|
||||||
|
// Cancel is the bordered one.
|
||||||
|
const modal = page.locator('.modal-backdrop')
|
||||||
|
await expect(modal).toBeVisible()
|
||||||
|
|
||||||
|
const title = `E2E Trip ${Date.now()}`
|
||||||
|
await modal.locator('input[type="text"]').first().fill(title)
|
||||||
|
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Authenticated smoke: the stored session lands on the dashboard and the
|
||||||
|
// app chrome (navbar) renders instead of bouncing back to /login.
|
||||||
|
test('authenticated session reaches the dashboard', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/)
|
||||||
|
// The shared Navbar shows the TREK brand once authenticated.
|
||||||
|
await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Infra smoke + first unauthenticated flow: the app boots, the backend is
|
||||||
|
// reachable through the Vite proxy, and the login screen renders its form.
|
||||||
|
test('login screen renders with a password field', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await expect(page.locator('input[type="password"]')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Boots the TREK backend for the Playwright E2E run against a fresh, isolated
|
||||||
|
// SQLite database. The DB file is deleted first so every run starts clean, then
|
||||||
|
// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD.
|
||||||
|
//
|
||||||
|
// The server is built once and launched as a SINGLE node process (not the
|
||||||
|
// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren
|
||||||
|
// that survive Playwright's teardown and then linger on :3001 with stale DB
|
||||||
|
// state). A single child is killed cleanly when Playwright tears the run down.
|
||||||
|
import { rmSync } from 'node:fs'
|
||||||
|
import { spawn, execSync } from 'node:child_process'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const dbFile = path.join(here, '.tmp', 'e2e.db')
|
||||||
|
const serverDir = path.join(here, '..', '..', 'server')
|
||||||
|
|
||||||
|
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
|
||||||
|
try { rmSync(f, { force: true }) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build once (no watcher) — the resulting process is a single killable node.
|
||||||
|
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
TREK_DB_FILE: dbFile,
|
||||||
|
ADMIN_EMAIL: 'e2e@trek.local',
|
||||||
|
ADMIN_PASSWORD: 'E2eTest12345!',
|
||||||
|
PORT: '3001',
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], {
|
||||||
|
cwd: serverDir,
|
||||||
|
env,
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
const stop = () => { try { child.kill() } catch {} }
|
||||||
|
process.on('SIGINT', stop)
|
||||||
|
process.on('SIGTERM', stop)
|
||||||
|
process.on('exit', stop)
|
||||||
|
child.on('exit', code => process.exit(code ?? 0))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// Open a trip into the planner: create a trip, open it from the dashboard, and
|
||||||
|
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
|
||||||
|
// mounts, proving the day-plan/map shell renders rather than crashing on load.
|
||||||
|
test('open a trip and land in the planner with a map', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
|
||||||
|
// Create a trip to open.
|
||||||
|
await page.locator('.add-trip-card').click()
|
||||||
|
const modal = page.locator('.modal-backdrop')
|
||||||
|
await expect(modal).toBeVisible()
|
||||||
|
const title = `E2E Planner ${Date.now()}`
|
||||||
|
await modal.locator('input[type="text"]').first().fill(title)
|
||||||
|
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||||
|
|
||||||
|
// Open it from the dashboard.
|
||||||
|
await page.getByText(title).first().click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||||
|
// The planner shows a Leaflet map once mounted (past the splash screen).
|
||||||
|
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
|
||||||
|
import gitignore from 'eslint-config-flat-gitignore';
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
|
||||||
|
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
|
||||||
|
// eslint-plugin-react (not a dependency here). The rule is a no-op.
|
||||||
|
const reactStub = {
|
||||||
|
rules: {
|
||||||
|
'no-danger': {
|
||||||
|
meta: { schema: [] },
|
||||||
|
create() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
gitignore({ strict: false }),
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'node_modules',
|
||||||
|
'dist',
|
||||||
|
'coverage',
|
||||||
|
'public',
|
||||||
|
'test-results',
|
||||||
|
'playwright-report',
|
||||||
|
'e2e/**',
|
||||||
|
'scripts/**',
|
||||||
|
'**/*.config.js',
|
||||||
|
'**/*.config.ts',
|
||||||
|
'**/*.config.mjs',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
eslintConfigPrettier,
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
react: reactStub,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react/no-danger': 'off',
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
|
||||||
|
// --- Severities tuned to keep CI green on a codebase that was never linted ---
|
||||||
|
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
|
||||||
|
|
||||||
|
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
|
||||||
|
'react-hooks/rules-of-hooks': 'warn',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unused-expressions': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-function-type': 'warn',
|
||||||
|
'@typescript-eslint/no-this-alias': 'warn',
|
||||||
|
'@typescript-eslint/no-non-null-asserted-optional-chain': 'warn',
|
||||||
|
|
||||||
|
// js.recommended rules with pre-existing hits.
|
||||||
|
'no-empty': 'warn',
|
||||||
|
'no-useless-escape': 'warn',
|
||||||
|
'no-useless-assignment': 'warn',
|
||||||
|
'preserve-caught-error': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<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>NOMAD</title>
|
<title>TREK</title>
|
||||||
|
|
||||||
<!-- PWA / iOS -->
|
<!-- PWA / iOS -->
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="NOMAD" />
|
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
@@ -19,9 +19,12 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<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" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=Poppins:wght@300;400;500;600;700&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>
|
||||||
|
|||||||
@@ -1,41 +1,84 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-client",
|
"name": "@trek/client",
|
||||||
"version": "2.6.1",
|
"version": "3.0.22",
|
||||||
"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",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"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",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:check": "eslint .",
|
||||||
|
"lint:pages": "node scripts/check-page-pattern.mjs",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"e2e:report": "playwright show-report",
|
||||||
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
|
"@trek/shared": "*",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"dexie": "^4.4.2",
|
||||||
|
"heic-to": "^1.4.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.2.0",
|
"mapbox-gl": "^3.22.0",
|
||||||
"react-dom": "^18.2.0",
|
"marked": "^18.0.0",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^4.1.3",
|
||||||
|
"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",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/leaflet": "^1.9.8",
|
"@types/leaflet": "^1.9.8",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@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",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"msw": "^2.13.0",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
"sharp": "^0.33.0",
|
"sharp": "^0.33.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E harness for TREK's critical user flows (FE7).
|
||||||
|
*
|
||||||
|
* Two web servers are orchestrated: the Express/Nest backend on :3001 against an
|
||||||
|
* isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a
|
||||||
|
* known admin), and the Vite dev server on :5173 which proxies /api, /uploads,
|
||||||
|
* /ws to the backend. Tests run serially against one worker so they share the
|
||||||
|
* single seeded database deterministically.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
timeout: 45_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
// Unauthenticated flows (login, register, public share) — no stored session.
|
||||||
|
{ name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
|
||||||
|
// One-time login that persists a session for the authenticated flows.
|
||||||
|
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||||
|
{
|
||||||
|
name: 'app',
|
||||||
|
testMatch: /\.spec\.ts/,
|
||||||
|
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
|
||||||
|
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
// Always start our own backend (never reuse) so the isolated test DB is
|
||||||
|
// reset + reseeded on every run, regardless of any stray dev server.
|
||||||
|
command: 'node e2e/server-launch.mjs',
|
||||||
|
port: 3001,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 180_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
// Guards the "Page = wiring container + data hook" convention (see
|
||||||
|
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
|
||||||
|
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
|
||||||
|
//
|
||||||
|
// We scan only the default-export component body (from `export default function`
|
||||||
|
// up to the next top-level `function` declaration or EOF), so presentational
|
||||||
|
// sub-components and helper hooks living in the same file are not flagged.
|
||||||
|
// Context hooks like useTranslation/useParams are fine; the smell is stateful
|
||||||
|
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs'
|
||||||
|
import { join, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
|
||||||
|
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
|
||||||
|
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
|
||||||
|
|
||||||
|
const violations = []
|
||||||
|
for (const file of readdirSync(pagesDir)) {
|
||||||
|
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
|
||||||
|
const src = readFileSync(join(pagesDir, file), 'utf8')
|
||||||
|
const lines = src.split('\n')
|
||||||
|
const start = lines.findIndex(l => /export default function/.test(l))
|
||||||
|
if (start === -1) continue
|
||||||
|
// The page body ends at the next top-level declaration (a `function` at
|
||||||
|
// column 0) — everything after that is a sub-component or helper.
|
||||||
|
let end = lines.length
|
||||||
|
for (let i = start + 1; i < lines.length; i++) {
|
||||||
|
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
||||||
|
}
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
if (bannedRe.test(lines[i])) {
|
||||||
|
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
console.error('Page-pattern violations — move this state/effect logic into the page\'s use<Page>() hook:\n')
|
||||||
|
for (const v of violations) console.error(' ' + v)
|
||||||
|
console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log('Page pattern OK — no state/effect logic in page containers.')
|
||||||
@@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 + location.hash)
|
||||||
|
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)
|
setDemoMode(!!config?.demo_mode)
|
||||||
|
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="/register" element={<Navigate to="/login" replace />} />
|
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||||
|
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||||
|
<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/consent" 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,46 +1,241 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import {
|
||||||
|
weatherResultSchema, type WeatherResult,
|
||||||
|
inAppListResultSchema, type InAppListResult,
|
||||||
|
unreadCountResultSchema, type UnreadCountResult,
|
||||||
|
channelTestResultSchema,
|
||||||
|
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
|
||||||
|
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
|
||||||
|
type NotificationRespondRequest,
|
||||||
|
type SettingUpsertRequest, type SettingsBulkRequest,
|
||||||
|
type JourneyCreateRequest, type JourneyAddTripRequest,
|
||||||
|
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
||||||
|
type JourneyShareLinkRequest,
|
||||||
|
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||||
|
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||||
|
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||||
|
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||||
|
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||||
|
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||||
|
type DayCreateRequest, type DayUpdateRequest,
|
||||||
|
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||||
|
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||||
|
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||||
|
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||||
|
type PackingCreateItemRequest, type PackingUpdateItemRequest,
|
||||||
|
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||||
|
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||||
|
type PlaceBulkDeleteRequest,
|
||||||
|
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||||
|
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||||
|
type PackingCategoryAssigneesRequest,
|
||||||
|
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||||
|
type TodoCategoryAssigneesRequest,
|
||||||
|
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||||
|
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
|
||||||
|
type FileUpdateRequest, type FileLinkRequest,
|
||||||
|
type CreateTagRequest, type UpdateTagRequest,
|
||||||
|
type CreateCategoryRequest, type UpdateCategoryRequest,
|
||||||
|
type PlaceImportListRequest,
|
||||||
|
} from '@trek/shared'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
|
|
||||||
const apiClient: AxiosInstance = axios.create({
|
/**
|
||||||
|
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||||
|
* dev, and never throwing. A drift between the server contract and the client's
|
||||||
|
* expected shape is surfaced as a console warning during development; in
|
||||||
|
* production (and on any mismatch) the data passes through untouched, so adding
|
||||||
|
* validation can never break a working call. This is the typed-request helper
|
||||||
|
* the FE adopts per domain as each backend module lands on @trek/shared.
|
||||||
|
*/
|
||||||
|
const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV)
|
||||||
|
export function parseInDev<S extends z.ZodTypeAny>(schema: S, data: unknown, label: string): z.infer<S> {
|
||||||
|
if (API_DEV) {
|
||||||
|
const result = schema.safeParse(data)
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data as z.infer<S>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same dev-only drift check as parseInDev, but passes the payload straight
|
||||||
|
* through with its original inferred type instead of the schema type. Use this
|
||||||
|
* for endpoints whose existing consumers rely on the loose `r.data` type — it
|
||||||
|
* adds the development contract-drift warning without retyping the public
|
||||||
|
* surface (so it can never break a consumer that worked before).
|
||||||
|
*/
|
||||||
|
function checkInDev<T>(schema: z.ZodTypeAny, data: T, label: string): T {
|
||||||
|
if (API_DEV) {
|
||||||
|
const result = schema.safeParse(data)
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||||
|
en: 'Too many attempts. Please try again later.',
|
||||||
|
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||||
|
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||||
|
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||||
|
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||||
|
nl: 'Te veel pogingen. Probeer het later opnieuw.',
|
||||||
|
br: 'Muitas tentativas. Tente novamente mais tarde.',
|
||||||
|
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||||
|
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
|
||||||
|
ru: 'Слишком много попыток. Попробуйте позже.',
|
||||||
|
zh: '尝试次数过多,请稍后再试。',
|
||||||
|
'zh-TW': '嘗試次數過多,請稍後再試。',
|
||||||
|
it: 'Troppi tentativi. Riprova più tardi.',
|
||||||
|
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||||
|
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||||
|
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||||
|
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||||
|
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||||
|
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateRateLimit(): string {
|
||||||
|
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||||
|
try {
|
||||||
|
const lang = localStorage.getItem('app_language') || 'en'
|
||||||
|
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
withCredentials: true,
|
||||||
|
timeout: 8000,
|
||||||
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')
|
const sid = getSocketId()
|
||||||
if (token) {
|
if (sid) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers['X-Socket-Id'] = sid
|
||||||
}
|
}
|
||||||
const sid = getSocketId()
|
// Attach a per-request idempotency key to all write operations so the
|
||||||
if (sid) {
|
// server can deduplicate retried requests (e.g. network blips).
|
||||||
config.headers['X-Socket-Id'] = sid
|
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||||
}
|
const method = (config.method ?? '').toLowerCase()
|
||||||
return config
|
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||||
},
|
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
(error) => Promise.reject(error)
|
? crypto.randomUUID()
|
||||||
|
: Math.random().toString(36).slice(2)
|
||||||
|
config.headers['X-Idempotency-Key'] = key
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||||
|
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||||
|
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||||
|
async function unregisterSWAndReload(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker?.getRegistration()
|
||||||
|
if (reg) await reg.unregister()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
(error) => {
|
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||||
if (error.response?.status === 401) {
|
return response
|
||||||
localStorage.removeItem('auth_token')
|
},
|
||||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
async (error) => {
|
||||||
window.location.href = '/login'
|
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||||
|
// as a CORS error with no response object. Probe the health endpoint to
|
||||||
|
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||||
|
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||||
|
if (!error.response && navigator.onLine) {
|
||||||
|
await probeNow()
|
||||||
|
// Both the original request and the health probe failed while the device
|
||||||
|
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||||
|
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||||
|
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||||
|
// down would also land here, but only reloads once).
|
||||||
|
if (!isReachable()) {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
|
await unregisterSWAndReload()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||||
|
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||||
|
// always application/json, so checking for text/html is unambiguous.
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
|
await unregisterSWAndReload()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname)) {
|
||||||
|
const currentPath = pathname + window.location.search + window.location.hash
|
||||||
|
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)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||||
|
login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||||
|
verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||||
|
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||||
|
mfaEnable: (data: MfaEnableRequest) => 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),
|
||||||
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),
|
||||||
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
||||||
@@ -53,72 +248,161 @@ export const authApi = {
|
|||||||
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
||||||
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: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||||
|
forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||||
|
resetPassword: (data: ResetPasswordRequest) => 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 } satisfies McpTokenCreateRequest).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
|
||||||
|
resource?: 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
|
||||||
|
resource?: string
|
||||||
|
}) => 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[]; allows_client_credentials?: boolean }) =>
|
||||||
|
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 = {
|
||||||
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
||||||
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
|
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
|
||||||
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
||||||
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||||
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 } satisfies TripAddMemberRequest).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?: TripCopyRequest) => 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 = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const placesApi = {
|
export const placesApi = {
|
||||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).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),
|
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => 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 } satisfies PlaceImportListRequest).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 } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
||||||
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
||||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
|
||||||
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
||||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
|
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||||
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
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: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, 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),
|
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data),
|
||||||
|
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => 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 } satisfies PackingReorderRequest).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 } satisfies PackingCategoryAssigneesRequest).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 } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||||
|
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||||
|
createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||||
|
updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const todoApi = {
|
||||||
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
||||||
|
create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||||
|
update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => 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 } satisfies TodoReorderRequest).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 } satisfies TodoCategoryAssigneesRequest).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: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data),
|
||||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||||
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const categoriesApi = {
|
export const categoriesApi = {
|
||||||
list: () => apiClient.get('/categories').then(r => r.data),
|
list: () => apiClient.get('/categories').then(r => r.data),
|
||||||
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
|
create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data),
|
||||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||||
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,86 +418,219 @@ 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),
|
||||||
|
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),
|
||||||
|
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),
|
||||||
|
updatePackingTemplate: (id: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${id}`, data).then(r => r.data),
|
||||||
|
deletePackingTemplate: (id: number) => apiClient.delete(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||||
|
addTemplateCategory: (templateId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories`, data).then(r => r.data),
|
||||||
|
updateTemplateCategory: (templateId: number, catId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/categories/${catId}`, data).then(r => r.data),
|
||||||
|
deleteTemplateCategory: (templateId: number, catId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/categories/${catId}`).then(r => r.data),
|
||||||
|
addTemplateItem: (templateId: number, catId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories/${catId}/items`, data).then(r => r.data),
|
||||||
|
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
|
||||||
|
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).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),
|
||||||
|
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: JourneyCreateRequest) => 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 } satisfies JourneyAddTripRequest).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 } satisfies JourneyReorderEntriesRequest).then(r => r.data),
|
||||||
|
|
||||||
|
// Photos
|
||||||
|
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
|
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).then(r => r.data),
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
|
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).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 } : {}) } satisfies JourneyProviderPhotosRequest).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: JourneyShareLinkRequest) => 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 => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
|
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||||
placePhoto: (placeId: string) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data),
|
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||||
|
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||||
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||||
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||||
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).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),
|
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).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 } satisfies BudgetToggleMemberPaidRequest).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 } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
|
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data),
|
||||||
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
||||||
|
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).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),
|
||||||
|
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||||
|
addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => 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 = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
upcoming: () => apiClient.get('/reservations/upcoming').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),
|
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||||
|
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => 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 = {
|
||||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||||
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): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
const body: SettingUpsertRequest = { key, value }
|
||||||
|
return apiClient.put('/settings', body).then(r => r.data)
|
||||||
|
},
|
||||||
|
setBulk: (settings: Record<string, unknown>) => {
|
||||||
|
const body: SettingsBulkRequest = { settings }
|
||||||
|
return apiClient.post('/settings/bulk', body).then(r => r.data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const accommodationsApi = {
|
export const accommodationsApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dayNotesApi = {
|
export const dayNotesApi = {
|
||||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
||||||
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const collabApi = {
|
export const collabApi = {
|
||||||
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
||||||
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||||
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||||
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
|
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||||
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
|
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,9 +638,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()
|
||||||
@@ -245,4 +661,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 default apiClient
|
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 => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||||
|
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||||
|
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inAppNotificationsApi = {
|
||||||
|
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise<InAppListResult> =>
|
||||||
|
apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')),
|
||||||
|
unreadCount: (): Promise<UnreadCountResult> =>
|
||||||
|
apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')),
|
||||||
|
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: NotificationRespondRequest['response']) =>
|
||||||
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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() {
|
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() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
@@ -80,16 +158,16 @@ export default function AddonManager() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||||
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{addons.length === 0 ? (
|
{addons.length === 0 ? (
|
||||||
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
|
<div className="p-8 text-center text-sm text-content-faint">
|
||||||
{t('admin.addons.noAddons')}
|
{t('admin.addons.noAddons')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -97,14 +175,66 @@ export default function AddonManager() {
|
|||||||
{/* Trip Addons */}
|
{/* Trip Addons */}
|
||||||
{tripAddons.length > 0 && (
|
{tripAddons.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||||
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
|
<Briefcase size={13} className="text-content-muted" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{tripAddons.map(addon => (
|
{tripAddons.map(addon => (
|
||||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
<div key={addon.id}>
|
||||||
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||||
|
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||||
|
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||||
|
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
|
</span>
|
||||||
|
<button onClick={onToggleBagTracking}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: bagTrackingEnabled ? '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: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||||
|
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ 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} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||||
|
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-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>
|
||||||
)}
|
)}
|
||||||
@@ -112,13 +242,62 @@ export default function AddonManager() {
|
|||||||
{/* Global Addons */}
|
{/* Global Addons */}
|
||||||
{globalAddons.length > 0 && (
|
{globalAddons.length > 0 && (
|
||||||
<div>
|
<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)' }}>
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||||
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
|
<Globe size={13} className="text-content-muted" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||||
</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 border-edge-secondary bg-surface-secondary" style={{ 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 className="text-content-faint"><ProviderIcon size={14} /></span>}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||||
|
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-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 bg-surface-secondary border-edge-secondary">
|
||||||
|
<Link2 size={13} className="text-content-muted" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-content-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>
|
||||||
@@ -132,57 +311,73 @@ export default function AddonManager() {
|
|||||||
|
|
||||||
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 AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||||
|
const nameKey = `admin.addons.catalog.${addon.id}.name`
|
||||||
|
const descKey = `admin.addons.catalog.${addon.id}.description`
|
||||||
|
const translatedName = t(nameKey)
|
||||||
|
const translatedDescription = t(descKey)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: translatedName !== nameKey ? translatedName : addon.name,
|
||||||
|
description: translatedDescription !== descKey ? translatedDescription : addon.description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
||||||
<AddonIcon name={addon.icon} size={20} />
|
<AddonIcon name={addon.icon} size={20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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)' }}>{addon.name}</span>
|
<span className="text-sm font-semibold text-content">{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 text-content-faint bg-surface-tertiary">
|
||||||
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 bg-surface-secondary text-content-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)' }}>{addon.description}</p>
|
<p className="text-xs mt-0.5 text-content-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="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-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 bg-surface-card"
|
||||||
background: 'var(--bg-card)',
|
style={{
|
||||||
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
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,257 @@
|
|||||||
|
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 text-content">{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 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||||
|
<div className="rounded-xl border overflow-hidden border-edge bg-surface-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 border-edge bg-surface-secondary"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<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 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate text-content">{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 bg-surface-secondary border border-edge"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{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 bg-surface-secondary text-content-secondary border border-edge">
|
||||||
|
+{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 bg-surface-secondary text-content-secondary border border-edge">
|
||||||
|
show less
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-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 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||||
|
<div className="rounded-xl border overflow-hidden border-edge bg-surface-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 border-edge bg-surface-secondary"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<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 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate text-content">{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 text-content-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 bg-[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 bg-surface-card">
|
||||||
|
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||||
|
<p className="text-sm text-content-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 border-edge text-content-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 bg-[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 bg-surface-card">
|
||||||
|
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||||
|
<p className="text-sm text-content-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 border-edge text-content-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,169 @@
|
|||||||
|
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 text-content">
|
||||||
|
<ClipboardList size={20} />
|
||||||
|
{t('admin.tabs.audit')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm m-0 mt-1 text-content-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 border-edge text-content bg-surface-card"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
{t('admin.audit.refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs m-0 text-content-faint">
|
||||||
|
{t('admin.audit.showing', { count: entries.length, total })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
|
||||||
|
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left border-edge-secondary">
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
|
||||||
|
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="border-b align-top border-edge-secondary">
|
||||||
|
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
|
||||||
|
<td className="p-3 text-content">{userLabel(e)}</td>
|
||||||
|
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
|
||||||
|
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
|
||||||
|
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
|
||||||
|
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-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 text-content-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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +186,8 @@ export default function BackupPanel() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
|
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
|
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -289,8 +310,8 @@ export default function BackupPanel() {
|
|||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<Clock className="w-5 h-5 text-gray-400" />
|
<Clock className="w-5 h-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
|
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
|
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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(String(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(String(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>
|
||||||
@@ -373,7 +458,8 @@ export default function BackupPanel() {
|
|||||||
{/* Restore Warning Modal */}
|
{/* Restore Warning Modal */}
|
||||||
{restoreConfirm && (
|
{restoreConfirm && (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
className="bg-[rgba(0,0,0,0.5)]"
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
onClick={() => setRestoreConfirm(null)}
|
onClick={() => setRestoreConfirm(null)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -383,14 +469,14 @@ export default function BackupPanel() {
|
|||||||
>
|
>
|
||||||
{/* Red header */}
|
{/* Red header */}
|
||||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
<AlertTriangle size={20} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
|
||||||
{t('backup.restoreConfirmTitle')}
|
{t('backup.restoreConfirmTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
|
||||||
{restoreConfirm.filename}
|
{restoreConfirm.filename}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +506,8 @@ export default function BackupPanel() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={executeRestore}
|
onClick={executeRestore}
|
||||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
|
className="bg-[#dc2626] text-white"
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -191,14 +191,13 @@ export default function CategoryManager() {
|
|||||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
|
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
|
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleStartCreate}
|
<button onClick={handleStartCreate}
|
||||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||||
<span className="sm:hidden">Add</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
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
|
||||||
|
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 text-content-secondary">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{hint && <p className="text-xs mb-2 text-content-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 text-content-faint underline"
|
||||||
|
style={{ 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,
|
||||||
|
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 className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkMode = defaults.dark_mode
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||||
|
<p className="text-sm text-content-faint" style={{ 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>
|
||||||
|
|
||||||
|
{/* 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 text-content-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 text-content-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,282 @@
|
|||||||
|
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 border-edge bg-surface-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 text-content">{label}</p>
|
||||||
|
<p className="text-xs truncate text-content-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 text-content-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 border-edge bg-surface-card text-content"
|
||||||
|
>
|
||||||
|
{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 border-edge bg-surface-card text-content"
|
||||||
|
>
|
||||||
|
{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 bg-[#fbbf24] text-[#000]">
|
||||||
|
DEV ONLY
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-content">
|
||||||
|
Notification Testing
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Type Testing</SectionTitle>
|
||||||
|
<p className="text-xs mb-3 text-content-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 text-content-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 text-content-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 text-content-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,37 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
|
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
|
||||||
|
tag_name: string
|
||||||
|
name: string | null
|
||||||
|
body: string | null
|
||||||
|
published_at: string | null
|
||||||
|
created_at: string
|
||||||
|
author: { login: string } | null
|
||||||
|
[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 fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`)
|
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||||
if (!res.ok) throw new Error(`GitHub API: ${res.status}`)
|
const data = Array.isArray(res.data) ? res.data : []
|
||||||
const data = await res.json()
|
|
||||||
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) {
|
||||||
@@ -46,7 +58,7 @@ export default function GitHubPanel() {
|
|||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||||
@@ -61,7 +73,7 @@ export default function GitHubPanel() {
|
|||||||
elements.push(
|
elements.push(
|
||||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||||
{listItems.map((item, i) => (
|
{listItems.map((item, i) => (
|
||||||
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||||
</li>
|
</li>
|
||||||
@@ -72,11 +84,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) {
|
||||||
@@ -86,14 +102,14 @@ export default function GitHubPanel() {
|
|||||||
if (trimmed.startsWith('### ')) {
|
if (trimmed.startsWith('### ')) {
|
||||||
flushList()
|
flushList()
|
||||||
elements.push(
|
elements.push(
|
||||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
|
||||||
{trimmed.slice(4)}
|
{trimmed.slice(4)}
|
||||||
</h4>
|
</h4>
|
||||||
)
|
)
|
||||||
} else if (trimmed.startsWith('## ')) {
|
} else if (trimmed.startsWith('## ')) {
|
||||||
flushList()
|
flushList()
|
||||||
elements.push(
|
elements.push(
|
||||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
|
||||||
{trimmed.slice(3)}
|
{trimmed.slice(3)}
|
||||||
</h3>
|
</h3>
|
||||||
)
|
)
|
||||||
@@ -102,7 +118,7 @@ export default function GitHubPanel() {
|
|||||||
} else {
|
} else {
|
||||||
flushList()
|
flushList()
|
||||||
elements.push(
|
elements.push(
|
||||||
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
|
<p key={elements.length} className="text-xs my-1 text-content-muted"
|
||||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -112,42 +128,143 @@ export default function GitHubPanel() {
|
|||||||
return elements
|
return elements
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="p-8 flex items-center justify-center">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="p-6 text-center">
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Header card */}
|
{/* Support cards */}
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
<a
|
||||||
|
href="https://ko-fi.com/mauriceboe"
|
||||||
|
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)] bg-surface-card border-edge no-underline"
|
||||||
|
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' }}
|
||||||
|
>
|
||||||
|
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
|
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/mauriceboe"
|
||||||
|
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)] bg-surface-card border-edge no-underline"
|
||||||
|
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' }}
|
||||||
|
>
|
||||||
|
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Heart size={20} className="text-[#ffdd00]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||||
|
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
|
</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)] bg-surface-card border-edge no-underline"
|
||||||
|
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 className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, 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 text-content">Discord</div>
|
||||||
|
<div className="text-xs text-content-faint">Join the community</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-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)] bg-surface-card border-edge no-underline"
|
||||||
|
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 className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Bug size={20} className="text-[#ef4444]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||||
|
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-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)] bg-surface-card border-edge no-underline"
|
||||||
|
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 className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||||
|
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-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)] bg-surface-card border-edge no-underline"
|
||||||
|
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 className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<BookOpen size={20} className="text-[#6366f1]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||||
|
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading / Error / Releases */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||||
|
<div className="p-8 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||||
|
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||||
|
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||||
|
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/${REPO}/releases`}
|
href={`https://github.com/${REPO}/releases`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
|
||||||
>
|
>
|
||||||
<ExternalLink size={12} />
|
<ExternalLink size={12} />
|
||||||
GitHub
|
GitHub
|
||||||
@@ -161,7 +278,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]
|
||||||
|
|
||||||
@@ -181,36 +298,34 @@ export default function GitHubPanel() {
|
|||||||
{/* Release content */}
|
{/* Release content */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
<span className="text-sm font-semibold text-content">
|
||||||
{release.tag_name}
|
{release.tag_name}
|
||||||
</span>
|
</span>
|
||||||
{isLatest && (
|
{isLatest && (
|
||||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
|
||||||
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
|
|
||||||
{t('admin.github.latest')}
|
{t('admin.github.latest')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{release.prerelease && (
|
{release.prerelease && (
|
||||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
|
||||||
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
|
|
||||||
{t('admin.github.prerelease')}
|
{t('admin.github.prerelease')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{release.name && release.name !== release.tag_name && (
|
{release.name && release.name !== release.tag_name && (
|
||||||
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
<p className="text-xs font-medium mt-0.5 text-content-muted">
|
||||||
{release.name}
|
{release.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||||
<Calendar size={10} />
|
<Calendar size={10} />
|
||||||
{formatDate(release.published_at || release.created_at)}
|
{formatDate(release.published_at || release.created_at)}
|
||||||
</span>
|
</span>
|
||||||
{release.author && (
|
{release.author && (
|
||||||
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
<span className="text-[11px] text-content-faint">
|
||||||
{t('admin.github.by')} {release.author.login}
|
{t('admin.github.by')} {release.author.login}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -221,15 +336,14 @@ export default function GitHubPanel() {
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExpand(release.id)}
|
onClick={() => toggleExpand(release.id)}
|
||||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||||
style={{ color: 'var(--text-muted)' }}
|
|
||||||
>
|
>
|
||||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
|
||||||
{renderBody(release.body)}
|
{renderBody(release.body)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -248,8 +362,7 @@ export default function GitHubPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleLoadMore}
|
onClick={handleLoadMore}
|
||||||
disabled={loadingMore}
|
disabled={loadingMore}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
|
||||||
>
|
>
|
||||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||||
@@ -258,6 +371,7 @@ export default function GitHubPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
// 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 createRowButtons = Array.from(createRow.querySelectorAll('button'));
|
||||||
|
const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement;
|
||||||
|
await user.click(cancelBtn);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
|
||||||
|
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
|
||||||
|
interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
|
||||||
|
|
||||||
|
export default function PackingTemplateManager() {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [createName, setCreateName] = useState('')
|
||||||
|
|
||||||
|
// Expanded template state
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
|
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||||
|
const [items, setItems] = useState<TemplateItem[]>([])
|
||||||
|
|
||||||
|
// Editing states
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
|
||||||
|
const [editTemplateName, setEditTemplateName] = useState('')
|
||||||
|
const [editingCatId, setEditingCatId] = useState<number | null>(null)
|
||||||
|
const [editCatName, setEditCatName] = useState('')
|
||||||
|
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||||
|
const [editItemName, setEditItemName] = useState('')
|
||||||
|
|
||||||
|
// Adding states
|
||||||
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
|
const [newCatName, setNewCatName] = useState('')
|
||||||
|
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
|
||||||
|
const [newItemName, setNewItemName] = useState('')
|
||||||
|
const addItemRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
useEffect(() => { loadTemplates() }, [])
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.packingTemplates()
|
||||||
|
setTemplates(data.templates || [])
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||||
|
finally { setIsLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpand = async (id: number) => {
|
||||||
|
if (expandedId === id) { setExpandedId(null); return }
|
||||||
|
setExpandedId(id)
|
||||||
|
setAddingCategory(false)
|
||||||
|
setAddingItemToCatId(null)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getPackingTemplate(id)
|
||||||
|
setCategories(data.categories || [])
|
||||||
|
setItems(data.items || [])
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template CRUD
|
||||||
|
const handleCreateTemplate = async () => {
|
||||||
|
if (!createName.trim()) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.createPackingTemplate({ name: createName.trim() })
|
||||||
|
setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
|
||||||
|
setCreateName(''); setShowCreate(false)
|
||||||
|
setExpandedId(data.template.id); setCategories([]); setItems([])
|
||||||
|
toast.success(t('admin.packingTemplates.created'))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.createError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deletePackingTemplate(id)
|
||||||
|
setTemplates(prev => prev.filter(t => t.id !== id))
|
||||||
|
if (expandedId === id) setExpandedId(null)
|
||||||
|
toast.success(t('admin.packingTemplates.deleted'))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameTemplate = async (id: number) => {
|
||||||
|
if (!editTemplateName.trim()) { setEditingTemplate(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
|
||||||
|
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
|
||||||
|
setEditingTemplate(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category CRUD
|
||||||
|
const handleAddCategory = async () => {
|
||||||
|
if (!newCatName.trim() || !expandedId) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
|
||||||
|
setCategories(prev => [...prev, data.category])
|
||||||
|
setNewCatName(''); setAddingCategory(false)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameCategory = async (catId: number) => {
|
||||||
|
if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
|
||||||
|
setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
|
||||||
|
setEditingCatId(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteCategory = async (catId: number) => {
|
||||||
|
if (!expandedId) return
|
||||||
|
try {
|
||||||
|
await adminApi.deleteTemplateCategory(expandedId, catId)
|
||||||
|
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||||
|
setItems(prev => prev.filter(i => i.category_id !== catId))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item CRUD
|
||||||
|
const handleAddItem = async (catId: number) => {
|
||||||
|
if (!newItemName.trim() || !expandedId) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||||
|
setItems(prev => [...prev, data.item])
|
||||||
|
setNewItemName('')
|
||||||
|
setTimeout(() => addItemRef.current?.focus(), 30)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameItem = async (itemId: number) => {
|
||||||
|
if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
|
||||||
|
setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
|
||||||
|
setEditingItemId(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteItem = async (itemId: number) => {
|
||||||
|
if (!expandedId) return
|
||||||
|
try {
|
||||||
|
await adminApi.deleteTemplateItem(expandedId, itemId)
|
||||||
|
setItems(prev => prev.filter(i => i.id !== itemId))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||||
|
const btnIcon = 'p-1.5 rounded-lg transition-colors'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(true)}
|
||||||
|
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 transition-colors">
|
||||||
|
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create template */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
|
||||||
|
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||||
|
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||||
|
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||||
|
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||||
|
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Template list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<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>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{templates.map(tmpl => (
|
||||||
|
<div key={tmpl.id}>
|
||||||
|
{/* Template row */}
|
||||||
|
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||||
|
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||||
|
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||||
|
</button>
|
||||||
|
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||||
|
{editingTemplate === tmpl.id ? (
|
||||||
|
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||||
|
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||||
|
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||||
|
) : (
|
||||||
|
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||||
|
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||||
|
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||||
|
<button onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||||
|
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
{expandedId === tmpl.id && (
|
||||||
|
<div className="px-5 pb-4 ml-8 space-y-3">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const catItems = items.filter(i => i.category_id === cat.id)
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||||
|
{/* Category header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
|
||||||
|
{editingCatId === cat.id ? (
|
||||||
|
<>
|
||||||
|
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
|
||||||
|
onBlur={() => handleRenameCategory(cat.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
|
||||||
|
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||||
|
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||||
|
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||||
|
<button onClick={() => handleDeleteCategory(cat.id)}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||||
|
<div className="divide-y divide-slate-50">
|
||||||
|
{catItems.map(item => (
|
||||||
|
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
|
||||||
|
{editingItemId === item.id ? (
|
||||||
|
<>
|
||||||
|
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||||
|
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||||
|
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||||
|
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||||
|
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||||
|
<button onClick={() => handleDeleteItem(item.id)}
|
||||||
|
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add item inline */}
|
||||||
|
{addingItemToCatId === cat.id && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2">
|
||||||
|
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||||
|
placeholder={t('admin.packingTemplates.itemName')}
|
||||||
|
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||||
|
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||||
|
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add category button */}
|
||||||
|
{addingCategory ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||||
|
placeholder={t('admin.packingTemplates.categoryName')}
|
||||||
|
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||||
|
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setAddingCategory(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||||
|
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,29 @@
|
|||||||
|
export const CURRENCIES = [
|
||||||
|
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||||
|
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||||
|
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||||
|
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||||
|
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SYMBOLS: Record<string, string> = {
|
||||||
|
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||||
|
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||||
|
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||||
|
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||||
|
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||||
|
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||||
|
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||||
|
PEN: 'S/.', ARS: 'AR$',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||||
|
|
||||||
|
export const SPLIT_COLORS = [
|
||||||
|
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
|
||||||
|
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
|
||||||
|
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
|
||||||
|
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
|
||||||
|
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
|
||||||
|
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||||
|
|
||||||
|
export function widgetTheme(dark: boolean) {
|
||||||
|
if (dark) return {
|
||||||
|
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
|
||||||
|
border: 'rgba(255,255,255,0.07)',
|
||||||
|
text: '#ffffff',
|
||||||
|
sub: 'rgba(255,255,255,0.6)',
|
||||||
|
faint: 'rgba(255,255,255,0.4)',
|
||||||
|
track: 'rgba(255,255,255,0.04)',
|
||||||
|
divider: 'rgba(255,255,255,0.07)',
|
||||||
|
iconBg: 'rgba(255,255,255,0.08)',
|
||||||
|
iconBorder: 'rgba(255,255,255,0.12)',
|
||||||
|
iconColor: 'rgba(255,255,255,0.9)',
|
||||||
|
centerBg: '#17171d',
|
||||||
|
flowBg: 'rgba(255,255,255,0.05)',
|
||||||
|
flowBorder: 'rgba(255,255,255,0.07)',
|
||||||
|
flowHoverBg: 'rgba(255,255,255,0.08)',
|
||||||
|
flowHoverBorder: 'rgba(255,255,255,0.12)',
|
||||||
|
rowHover: 'rgba(255,255,255,0.03)',
|
||||||
|
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||||
|
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
|
||||||
|
border: 'rgba(15,23,42,0.08)',
|
||||||
|
text: '#111827',
|
||||||
|
sub: 'rgba(17,24,39,0.6)',
|
||||||
|
faint: 'rgba(17,24,39,0.4)',
|
||||||
|
track: 'rgba(15,23,42,0.05)',
|
||||||
|
divider: 'rgba(15,23,42,0.08)',
|
||||||
|
iconBg: 'rgba(15,23,42,0.05)',
|
||||||
|
iconBorder: 'rgba(15,23,42,0.1)',
|
||||||
|
iconColor: 'rgba(17,24,39,0.75)',
|
||||||
|
centerBg: '#ffffff',
|
||||||
|
flowBg: 'rgba(15,23,42,0.03)',
|
||||||
|
flowBorder: 'rgba(15,23,42,0.08)',
|
||||||
|
flowHoverBg: 'rgba(15,23,42,0.06)',
|
||||||
|
flowHoverBorder: 'rgba(15,23,42,0.14)',
|
||||||
|
rowHover: 'rgba(15,23,42,0.04)',
|
||||||
|
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
|
||||||
|
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexLighten(hex: string, amount: number): string {
|
||||||
|
const m = hex.replace('#', '').match(/.{2}/g)
|
||||||
|
if (!m || m.length !== 3) return hex
|
||||||
|
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
|
||||||
|
const [r, g, b] = m.map(x => parseInt(x, 16))
|
||||||
|
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
|
||||||
|
if (v == null || isNaN(v)) return '-'
|
||||||
|
const d = currencyDecimals(cur)
|
||||||
|
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
type NumOrNull = number | null | undefined
|
||||||
|
|
||||||
|
export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / (n as number) : null)
|
||||||
|
export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null)
|
||||||
|
export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null)
|
||||||
|
|
||||||
|
export function splitColorFor(userId: number, order: number) {
|
||||||
|
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorForUserId(userId: number) {
|
||||||
|
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||||
|
}
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
// 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} />);
|
||||||
|
// 'Transport' appears in the category section header and the spend breakdown chart.
|
||||||
|
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
// 'Total' appears both as a table header and in the chart total label.
|
||||||
|
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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} />);
|
||||||
|
// Each category appears in its section header and again in the breakdown chart.
|
||||||
|
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||||
|
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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} />);
|
||||||
|
// 'ToDelete' appears in the category header and the breakdown chart.
|
||||||
|
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
|
||||||
|
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: 0 }],
|
||||||
|
};
|
||||||
|
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, user_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, user_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, user_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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,371 +1,12 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import { Plus, Calculator, Download } from 'lucide-react'
|
||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
|
||||||
import DOM from 'react-dom'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react'
|
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { budgetApi } from '../../api/client'
|
import { CURRENCIES, SYMBOLS } from './BudgetPanel.constants'
|
||||||
import type { BudgetItem, BudgetMember } from '../../types'
|
import { useBudgetPanel } from './useBudgetPanel'
|
||||||
|
import type { TripMember } from './BudgetPanelMemberChips'
|
||||||
|
import BudgetCategoryTable from './BudgetPanelCategoryTable'
|
||||||
|
import BudgetSummary from './BudgetPanelSummary'
|
||||||
|
|
||||||
interface TripMember {
|
export { splitColorFor } from './BudgetPanel.helpers'
|
||||||
id: number
|
|
||||||
username: string
|
|
||||||
avatar_url?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PieSegment {
|
|
||||||
label: string
|
|
||||||
value: number
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PerPersonSummaryEntry {
|
|
||||||
user_id: number
|
|
||||||
username: string
|
|
||||||
avatar_url: string | null
|
|
||||||
total_assigned: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
|
||||||
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
|
|
||||||
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
|
||||||
|
|
||||||
const fmtNum = (v, locale, cur) => {
|
|
||||||
if (v == null || isNaN(v)) return '-'
|
|
||||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur)
|
|
||||||
}
|
|
||||||
|
|
||||||
const calcPP = (p, n) => (n > 0 ? p / n : null)
|
|
||||||
const calcPD = (p, d) => (d > 0 ? p / d : null)
|
|
||||||
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
|
||||||
|
|
||||||
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
|
||||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [editValue, setEditValue] = useState(value ?? '')
|
|
||||||
const inputRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
|
||||||
|
|
||||||
const save = () => {
|
|
||||||
setEditing(false)
|
|
||||||
let v = editValue
|
|
||||||
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
|
||||||
if (v !== value) onSave(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editing) {
|
|
||||||
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
|
||||||
onChange={e => setEditValue(e.target.value)} onBlur={save}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
|
||||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
|
||||||
placeholder={placeholder} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const display = type === 'number' && value != null
|
|
||||||
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
|
||||||
: (value || '')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
|
||||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
|
||||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
|
||||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
|
||||||
{display || placeholder || '-'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
|
||||||
interface AddItemRowProps {
|
|
||||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
|
|
||||||
t: (key: string) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [price, setPrice] = useState('')
|
|
||||||
const [persons, setPersons] = useState('')
|
|
||||||
const [days, setDays] = useState('')
|
|
||||||
const [note, setNote] = useState('')
|
|
||||||
const nameRef = useRef(null)
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
if (!name.trim()) return
|
|
||||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
|
|
||||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
|
|
||||||
setTimeout(() => nameRef.current?.focus(), 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr style={{ background: 'var(--bg-secondary)' }}>
|
|
||||||
<td style={{ padding: '4px 6px' }}>
|
|
||||||
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
|
||||||
placeholder={t('budget.newEntry')} style={inp} />
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '4px 6px' }}>
|
|
||||||
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
|
||||||
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
|
||||||
</td>
|
|
||||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
|
||||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
|
||||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
|
||||||
</td>
|
|
||||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
|
||||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
|
||||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
|
||||||
</td>
|
|
||||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
|
||||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
|
||||||
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
|
||||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
|
||||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
|
||||||
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
|
||||||
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
|
||||||
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
|
||||||
<Plus size={14} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
|
||||||
interface ChipWithTooltipProps {
|
|
||||||
label: string
|
|
||||||
avatarUrl: string | null
|
|
||||||
size?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
|
|
||||||
const [hover, setHover] = useState(false)
|
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
||||||
const ref = useRef(null)
|
|
||||||
|
|
||||||
const onEnter = () => {
|
|
||||||
if (ref.current) {
|
|
||||||
const rect = ref.current.getBoundingClientRect()
|
|
||||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
|
||||||
}
|
|
||||||
setHover(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
|
||||||
style={{
|
|
||||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{avatarUrl
|
|
||||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
: label?.[0]?.toUpperCase()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{hover && ReactDOM.createPortal(
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
|
||||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
|
||||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
|
||||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
|
||||||
}}>
|
|
||||||
{label}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
|
||||||
interface BudgetMemberChipsProps {
|
|
||||||
members?: BudgetMember[]
|
|
||||||
tripMembers?: TripMember[]
|
|
||||||
onSetMembers: (memberIds: number[]) => void
|
|
||||||
compact?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
|
|
||||||
const chipSize = compact ? 20 : 30
|
|
||||||
const btnSize = compact ? 18 : 28
|
|
||||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
|
||||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
|
|
||||||
const btnRef = useRef(null)
|
|
||||||
const dropRef = useRef(null)
|
|
||||||
|
|
||||||
const openDropdown = useCallback(() => {
|
|
||||||
if (btnRef.current) {
|
|
||||||
const rect = btnRef.current.getBoundingClientRect()
|
|
||||||
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
|
||||||
}
|
|
||||||
setShowDropdown(v => !v)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showDropdown) return
|
|
||||||
const close = (e) => {
|
|
||||||
if (dropRef.current && dropRef.current.contains(e.target)) return
|
|
||||||
if (btnRef.current && btnRef.current.contains(e.target)) return
|
|
||||||
setShowDropdown(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', close)
|
|
||||||
return () => document.removeEventListener('mousedown', close)
|
|
||||||
}, [showDropdown])
|
|
||||||
|
|
||||||
const memberIds = members.map(m => m.user_id)
|
|
||||||
|
|
||||||
const toggleMember = (userId) => {
|
|
||||||
const newIds = memberIds.includes(userId)
|
|
||||||
? memberIds.filter(id => id !== userId)
|
|
||||||
: [...memberIds, userId]
|
|
||||||
onSetMembers(newIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
|
||||||
{members.map(m => (
|
|
||||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize} />
|
|
||||||
))}
|
|
||||||
<button ref={btnRef} onClick={openDropdown}
|
|
||||||
style={{
|
|
||||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
|
||||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
|
||||||
</button>
|
|
||||||
{showDropdown && ReactDOM.createPortal(
|
|
||||||
<div ref={dropRef} style={{
|
|
||||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
|
||||||
}}>
|
|
||||||
{tripMembers.map(tm => {
|
|
||||||
const isActive = memberIds.includes(tm.id)
|
|
||||||
return (
|
|
||||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
|
||||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
|
||||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
|
||||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{tm.avatar_url
|
|
||||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
: tm.username?.[0]?.toUpperCase()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<span style={{ flex: 1 }}>{tm.username}</span>
|
|
||||||
{isActive && <Check size={12} color="var(--text-primary)" />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-Person Inline (inside total card) ────────────────────────────────────
|
|
||||||
interface PerPersonInlineProps {
|
|
||||||
tripId: number
|
|
||||||
budgetItems: BudgetItem[]
|
|
||||||
currency: string
|
|
||||||
locale: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
|
|
||||||
const [data, setData] = useState(null)
|
|
||||||
const fmt = (v) => fmtNum(v, locale, currency)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
|
||||||
}, [tripId, budgetItems])
|
|
||||||
|
|
||||||
if (!data || data.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{data.map(person => (
|
|
||||||
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
|
|
||||||
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{person.avatar_url
|
|
||||||
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
: person.username?.[0]?.toUpperCase()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
|
||||||
interface PieChartProps {
|
|
||||||
segments: PieSegment[]
|
|
||||||
size?: number
|
|
||||||
totalLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
|
||||||
if (!segments.length) return null
|
|
||||||
|
|
||||||
const total = segments.reduce((s, x) => s + x.value, 0)
|
|
||||||
if (total === 0) return null
|
|
||||||
|
|
||||||
let cumDeg = 0
|
|
||||||
const stops = segments.map(seg => {
|
|
||||||
const start = cumDeg
|
|
||||||
const deg = (seg.value / total) * 360
|
|
||||||
cumDeg += deg
|
|
||||||
return `${seg.color} ${start}deg ${start + deg}deg`
|
|
||||||
}).join(', ')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
|
||||||
<div style={{
|
|
||||||
width: size, height: size, borderRadius: '50%',
|
|
||||||
background: `conic-gradient(${stops})`,
|
|
||||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
|
||||||
}} />
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: '50%', left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
width: size * 0.55, height: size * 0.55,
|
|
||||||
borderRadius: '50%', background: 'var(--bg-card)',
|
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
||||||
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
|
||||||
}}>
|
|
||||||
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main Component ───────────────────────────────────────────────────────────
|
// ── Main Component ───────────────────────────────────────────────────────────
|
||||||
interface BudgetPanelProps {
|
interface BudgetPanelProps {
|
||||||
@@ -374,59 +15,21 @@ interface BudgetPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
|
const {
|
||||||
const { t, locale } = useTranslation()
|
budgetItems,
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
|
||||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
t, locale, isDark, theme,
|
||||||
const currency = trip?.currency || 'EUR'
|
newCategoryName, setNewCategoryName,
|
||||||
|
editingCat, setEditingCat,
|
||||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
settlement, settlementOpen, setSettlementOpen,
|
||||||
const hasMultipleMembers = tripMembers.length > 1
|
currency, canEdit, fmt, hasMultipleMembers,
|
||||||
|
dragCat, setDragCat, dragOverCat, setDragOverCat,
|
||||||
const setCurrency = (cur) => {
|
dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
|
||||||
if (tripId) updateTrip(tripId, { currency: cur })
|
setCurrency,
|
||||||
}
|
grouped, categoryNames, categoryColor, grandTotal, pieSegments,
|
||||||
|
handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
|
||||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
th, td,
|
||||||
|
} = useBudgetPanel(tripId, tripMembers)
|
||||||
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
|
|
||||||
const cat = item.category || 'Other'
|
|
||||||
if (!acc[cat]) acc[cat] = []
|
|
||||||
acc[cat].push(item)
|
|
||||||
return acc
|
|
||||||
}, {}), [budgetItems])
|
|
||||||
|
|
||||||
const categoryNames = Object.keys(grouped)
|
|
||||||
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
|
|
||||||
|
|
||||||
const pieSegments = useMemo(() =>
|
|
||||||
categoryNames.map((cat, i) => ({
|
|
||||||
name: cat,
|
|
||||||
value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
|
|
||||||
color: PIE_COLORS[i % PIE_COLORS.length],
|
|
||||||
})).filter(s => s.value > 0)
|
|
||||||
, [grouped, categoryNames])
|
|
||||||
|
|
||||||
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
|
|
||||||
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
|
|
||||||
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
|
|
||||||
const handleDeleteCategory = async (cat) => {
|
|
||||||
const items = grouped[cat] || []
|
|
||||||
for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id)
|
|
||||||
}
|
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
|
||||||
if (!newName.trim() || newName.trim() === oldName) return
|
|
||||||
const items = grouped[oldName] || []
|
|
||||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
|
||||||
}
|
|
||||||
const handleAddCategory = () => {
|
|
||||||
if (!newCategoryName.trim()) return
|
|
||||||
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
|
||||||
setNewCategoryName('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
|
||||||
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
|
||||||
|
|
||||||
// ── Empty State ──────────────────────────────────────────────────────────
|
// ── Empty State ──────────────────────────────────────────────────────────
|
||||||
if (!budgetItems || budgetItems.length === 0) {
|
if (!budgetItems || budgetItems.length === 0) {
|
||||||
@@ -437,233 +40,106 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
{canEdit && (
|
||||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||||
placeholder={t('budget.emptyPlaceholder')}
|
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
placeholder={t('budget.emptyPlaceholder')}
|
||||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||||
<Plus size={16} />
|
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||||
</button>
|
<Plus size={16} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main Layout ──────────────────────────────────────────────────────────
|
// ── Main Layout ──────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{
|
||||||
<Calculator size={20} color="var(--text-primary)" />
|
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
padding: '14px 16px 14px 22px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
|
{t('budget.title')}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||||
|
<div className="max-md:!w-full" style={{ width: 150 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={currency}
|
||||||
|
onChange={setCurrency}
|
||||||
|
disabled={!canEdit}
|
||||||
|
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||||
|
<input
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={e => setNewCategoryName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||||
|
placeholder={t('budget.categoryName')}
|
||||||
|
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||||
|
title={t('budget.addCategory')}
|
||||||
|
style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', 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,
|
||||||
|
opacity: newCategoryName.trim() ? 1 : 0.4,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}>
|
||||||
|
<Plus size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||||
|
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,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
>
|
||||||
|
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{categoryNames.map((cat, ci) => {
|
{categoryNames.map(cat => (
|
||||||
const items = grouped[cat]
|
<BudgetCategoryTable key={cat} cat={cat} grouped={grouped} categoryColor={categoryColor}
|
||||||
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
canEdit={canEdit} editingCat={editingCat} setEditingCat={setEditingCat}
|
||||||
const color = PIE_COLORS[ci % PIE_COLORS.length]
|
dragCat={dragCat} setDragCat={setDragCat} dragOverCat={dragOverCat} setDragOverCat={setDragOverCat}
|
||||||
|
dragItem={dragItem} setDragItem={setDragItem} dragOverItem={dragOverItem} setDragOverItem={setDragOverItem}
|
||||||
return (
|
dragItemCat={dragItemCat} setDragItemCat={setDragItemCat}
|
||||||
<div key={cat} style={{ marginBottom: 16 }}>
|
categoryNames={categoryNames} reorderBudgetCategories={reorderBudgetCategories} reorderBudgetItems={reorderBudgetItems}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
handleRenameCategory={handleRenameCategory} handleDeleteCategory={handleDeleteCategory} handleDeleteItem={handleDeleteItem}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
handleUpdateField={handleUpdateField} handleAddItem={handleAddItem}
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
tripId={tripId} currency={currency} locale={locale} t={t} fmt={fmt}
|
||||||
{editingCat?.name === cat ? (
|
hasMultipleMembers={hasMultipleMembers} tripMembers={tripMembers}
|
||||||
<input
|
setBudgetItemMembers={setBudgetItemMembers} toggleBudgetMemberPaid={toggleBudgetMemberPaid}
|
||||||
autoFocus
|
th={th} td={td} />
|
||||||
value={editingCat.value}
|
))}
|
||||||
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
|
||||||
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
|
||||||
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
|
||||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
|
||||||
<Pencil size={10} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
|
||||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
|
||||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
|
||||||
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
|
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
|
|
||||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
|
|
||||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
|
||||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
|
|
||||||
<th style={{ ...th, width: 36 }}></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map(item => {
|
|
||||||
const pp = calcPP(item.total_price, item.persons)
|
|
||||||
const pd = calcPD(item.total_price, item.days)
|
|
||||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
|
||||||
const hasMembers = item.members?.length > 0
|
|
||||||
return (
|
|
||||||
<tr key={item.id} style={{ transition: 'background 0.1s' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
|
||||||
<td style={td}>
|
|
||||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
|
||||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
|
||||||
{hasMultipleMembers && (
|
|
||||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
|
||||||
<BudgetMemberChips
|
|
||||||
members={item.members || []}
|
|
||||||
tripMembers={tripMembers}
|
|
||||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
|
||||||
compact={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ ...td, textAlign: 'center' }}>
|
|
||||||
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
|
|
||||||
</td>
|
|
||||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
|
||||||
{hasMultipleMembers ? (
|
|
||||||
<BudgetMemberChips
|
|
||||||
members={item.members || []}
|
|
||||||
tripMembers={tripMembers}
|
|
||||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
|
||||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
|
||||||
</td>
|
|
||||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
|
||||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
|
||||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
|
||||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
|
||||||
<td style={{ ...td, textAlign: 'center' }}>
|
|
||||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
<BudgetSummary theme={theme} currency={currency} locale={locale} grandTotal={grandTotal}
|
||||||
<div style={{ marginBottom: 12 }}>
|
hasMultipleMembers={hasMultipleMembers} budgetItems={budgetItems} settlement={settlement}
|
||||||
<CustomSelect
|
settlementOpen={settlementOpen} setSettlementOpen={setSettlementOpen} pieSegments={pieSegments}
|
||||||
value={currency}
|
isDark={isDark} tripId={tripId} t={t} fmt={fmt} />
|
||||||
onChange={setCurrency}
|
|
||||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
|
||||||
<input
|
|
||||||
value={newCategoryName}
|
|
||||||
onChange={e => setNewCategoryName(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
|
||||||
placeholder={t('budget.categoryName')}
|
|
||||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
|
||||||
/>
|
|
||||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
|
||||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
|
||||||
<Plus size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
|
||||||
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
|
|
||||||
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
|
||||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<Wallet size={18} color="rgba(255,255,255,0.8)" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
|
||||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
|
||||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
|
||||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pieSegments.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
|
||||||
border: '1px solid var(--border-primary)',
|
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
|
||||||
|
|
||||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
|
||||||
|
|
||||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{pieSegments.map(seg => {
|
|
||||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
|
||||||
return (
|
|
||||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
|
||||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{pieSegments.map(seg => (
|
|
||||||
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
|
||||||
|
interface AddItemRowProps {
|
||||||
|
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [price, setPrice] = useState('')
|
||||||
|
const [persons, setPersons] = useState('')
|
||||||
|
const [days, setDays] = useState('')
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [expenseDate, setExpenseDate] = useState('')
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||||
|
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||||
|
setTimeout(() => nameRef.current?.focus(), 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="bg-surface-secondary">
|
||||||
|
<td style={{ padding: '4px 6px' }}>
|
||||||
|
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
|
placeholder={t('budget.newEntry')} style={inp} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '4px 6px' }}>
|
||||||
|
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
|
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
|
||||||
|
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
||||||
|
</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
|
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
|
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||||
|
</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
|
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
|
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||||
|
</td>
|
||||||
|
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||||
|
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||||
|
<td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
|
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||||
|
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||||
|
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
|
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||||
|
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||||
|
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
||||||
|
import { Trash2, Pencil, GripVertical } from 'lucide-react'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||||
|
import InlineEditCell from './BudgetPanelInlineEditCell'
|
||||||
|
import AddItemRow from './BudgetPanelAddItemRow'
|
||||||
|
import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips'
|
||||||
|
import type { EditingCat, AddItemData } from './useBudgetPanel'
|
||||||
|
|
||||||
|
interface BudgetCategoryTableProps {
|
||||||
|
cat: string
|
||||||
|
grouped: Map<string, BudgetItem[]>
|
||||||
|
categoryColor: (cat: string) => string
|
||||||
|
canEdit: boolean
|
||||||
|
editingCat: EditingCat | null
|
||||||
|
setEditingCat: Dispatch<SetStateAction<EditingCat | null>>
|
||||||
|
dragCat: string | null
|
||||||
|
setDragCat: Dispatch<SetStateAction<string | null>>
|
||||||
|
dragOverCat: string | null
|
||||||
|
setDragOverCat: Dispatch<SetStateAction<string | null>>
|
||||||
|
dragItem: number | null
|
||||||
|
setDragItem: Dispatch<SetStateAction<number | null>>
|
||||||
|
dragOverItem: number | null
|
||||||
|
setDragOverItem: Dispatch<SetStateAction<number | null>>
|
||||||
|
dragItemCat: string | null
|
||||||
|
setDragItemCat: Dispatch<SetStateAction<string | null>>
|
||||||
|
categoryNames: string[]
|
||||||
|
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
|
||||||
|
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||||
|
handleRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||||
|
handleDeleteCategory: (cat: string) => Promise<void>
|
||||||
|
handleDeleteItem: (id: number) => Promise<void>
|
||||||
|
handleUpdateField: (id: number, field: string, value: unknown) => Promise<void>
|
||||||
|
handleAddItem: (category: string, data: AddItemData) => Promise<void>
|
||||||
|
tripId: number
|
||||||
|
currency: string
|
||||||
|
locale: string
|
||||||
|
t: (key: string) => string
|
||||||
|
fmt: (v: number | null | undefined, cur: string) => string
|
||||||
|
hasMultipleMembers: boolean
|
||||||
|
tripMembers: TripMember[]
|
||||||
|
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }>
|
||||||
|
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||||
|
th: CSSProperties
|
||||||
|
td: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat,
|
||||||
|
dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem,
|
||||||
|
dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems,
|
||||||
|
handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem,
|
||||||
|
tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) {
|
||||||
|
const items = grouped.get(cat) || []
|
||||||
|
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||||
|
const color = categoryColor(cat)
|
||||||
|
return (
|
||||||
|
<div key={cat} data-drag-cat={cat} style={{
|
||||||
|
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onDragOver={e => {
|
||||||
|
if (!dragCat || dragCat === cat || dragItem) return
|
||||||
|
e.preventDefault(); e.dataTransfer.dropEffect = 'move'
|
||||||
|
setDragOverCat(cat)
|
||||||
|
}}
|
||||||
|
onDragLeave={e => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null)
|
||||||
|
}}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragCat && dragCat !== cat) {
|
||||||
|
const newOrder = [...categoryNames]
|
||||||
|
const fromIdx = newOrder.indexOf(dragCat)
|
||||||
|
const toIdx = newOrder.indexOf(cat)
|
||||||
|
newOrder.splice(fromIdx, 1)
|
||||||
|
newOrder.splice(toIdx, 0, dragCat)
|
||||||
|
reorderBudgetCategories(tripId, newOrder)
|
||||||
|
}
|
||||||
|
setDragCat(null); setDragOverCat(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff',
|
||||||
|
borderRadius: '10px 10px 0 0', padding: '9px 14px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||||
|
{canEdit && (
|
||||||
|
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }}
|
||||||
|
onDragEnd={() => { setDragCat(null); setDragOverCat(null) }}
|
||||||
|
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||||
|
{canEdit && editingCat?.name === cat ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editingCat.value}
|
||||||
|
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
||||||
|
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
||||||
|
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||||
|
{canEdit && (
|
||||||
|
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||||
|
{canEdit && (
|
||||||
|
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||||
|
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}
|
||||||
|
onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||||
|
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||||
|
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||||
|
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||||
|
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||||
|
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||||
|
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||||
|
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||||
|
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||||
|
<th style={{ ...th, width: 36 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(item => {
|
||||||
|
const pp = calcPP(item.total_price, item.persons)
|
||||||
|
const pd = calcPD(item.total_price, item.days)
|
||||||
|
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||||
|
const hasMembers = (item.members?.length ?? 0) > 0
|
||||||
|
return (
|
||||||
|
<tr key={item.id}
|
||||||
|
style={{
|
||||||
|
transition: 'background 0.1s, opacity 0.15s',
|
||||||
|
opacity: dragItem === item.id ? 0.4 : 1,
|
||||||
|
boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none',
|
||||||
|
}}
|
||||||
|
onDragOver={e => {
|
||||||
|
if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return }
|
||||||
|
if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) }
|
||||||
|
}}
|
||||||
|
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }}
|
||||||
|
onDrop={e => {
|
||||||
|
if (dragItem && dragItemCat === cat && dragItem !== item.id) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
const ids = items.map(i => i.id)
|
||||||
|
const fromIdx = ids.indexOf(dragItem)
|
||||||
|
const toIdx = ids.indexOf(item.id)
|
||||||
|
ids.splice(fromIdx, 1)
|
||||||
|
ids.splice(toIdx, 0, dragItem)
|
||||||
|
reorderBudgetItems(tripId, ids)
|
||||||
|
setDragItem(null); setDragOverItem(null); setDragItemCat(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
|
<td style={td}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{canEdit && (
|
||||||
|
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
|
||||||
|
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
|
||||||
|
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||||
|
<GripVertical size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
|
||||||
|
{hasMultipleMembers && (
|
||||||
|
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||||
|
<BudgetMemberChips
|
||||||
|
members={item.members || []}
|
||||||
|
tripMembers={tripMembers}
|
||||||
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
|
compact={false}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, textAlign: 'center' }}>
|
||||||
|
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||||
|
</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||||
|
{hasMultipleMembers ? (
|
||||||
|
<BudgetMemberChips
|
||||||
|
members={item.members || []}
|
||||||
|
tripMembers={tripMembers}
|
||||||
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||||
|
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||||
|
</td>
|
||||||
|
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||||
|
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||||
|
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||||
|
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||||
|
{canEdit ? (
|
||||||
|
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||||
|
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||||
|
<td style={{ ...td, textAlign: 'center' }}>
|
||||||
|
{canEdit && (
|
||||||
|
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface InlineEditCellProps {
|
||||||
|
value: string | number | null | undefined
|
||||||
|
onSave: (value: string | number | null) => void
|
||||||
|
type?: 'text' | 'number'
|
||||||
|
style?: React.CSSProperties
|
||||||
|
placeholder?: string
|
||||||
|
decimals?: number
|
||||||
|
locale: string
|
||||||
|
editTooltip?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [editValue, setEditValue] = useState<string | number>(value ?? '')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setEditing(false)
|
||||||
|
let v: string | number | null = editValue
|
||||||
|
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||||
|
if (v !== value) onSave(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
if (type !== 'number') return
|
||||||
|
e.preventDefault()
|
||||||
|
let text = e.clipboardData.getData('text').trim()
|
||||||
|
// Strip everything except digits, dots, commas, minus
|
||||||
|
text = text.replace(/[^\d.,-]/g, '')
|
||||||
|
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
|
||||||
|
const lastComma = text.lastIndexOf(',')
|
||||||
|
const lastDot = text.lastIndexOf('.')
|
||||||
|
const decimalPos = Math.max(lastComma, lastDot)
|
||||||
|
if (decimalPos > -1) {
|
||||||
|
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
|
||||||
|
const decPart = text.substring(decimalPos + 1)
|
||||||
|
text = intPart + '.' + decPart
|
||||||
|
} else {
|
||||||
|
text = text.replace(/[.,]/g, '')
|
||||||
|
}
|
||||||
|
setEditValue(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||||
|
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||||
|
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||||
|
placeholder={placeholder} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = type === 'number' && value != null
|
||||||
|
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
: (value || '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||||
|
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||||
|
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||||
|
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||||
|
{display || placeholder || '-'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Pencil, Users, Check } from 'lucide-react'
|
||||||
|
import type { BudgetItemMember } from '../../types'
|
||||||
|
|
||||||
|
export interface TripMember {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
avatar_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||||
|
interface ChipWithTooltipProps {
|
||||||
|
label: string
|
||||||
|
avatarUrl: string | null
|
||||||
|
size?: number
|
||||||
|
paid?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
if (ref.current) {
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||||
|
}
|
||||||
|
setHover(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||||
|
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||||
|
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||||
|
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
}}>
|
||||||
|
{avatarUrl
|
||||||
|
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: label?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{hover && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||||
|
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
{paid && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||||
|
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||||
|
}}>Paid</span>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||||
|
interface BudgetMemberChipsProps {
|
||||||
|
members?: BudgetItemMember[]
|
||||||
|
tripMembers?: TripMember[]
|
||||||
|
onSetMembers: (memberIds: number[]) => void
|
||||||
|
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||||
|
compact?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
|
||||||
|
const chipSize = compact ? 20 : 30
|
||||||
|
const btnSize = compact ? 18 : 28
|
||||||
|
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const openDropdown = useCallback(() => {
|
||||||
|
if (btnRef.current) {
|
||||||
|
const rect = btnRef.current.getBoundingClientRect()
|
||||||
|
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
||||||
|
}
|
||||||
|
setShowDropdown(v => !v)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showDropdown) return
|
||||||
|
const close = (e: MouseEvent) => {
|
||||||
|
if (dropRef.current && dropRef.current.contains(e.target as Node)) return
|
||||||
|
if (btnRef.current && btnRef.current.contains(e.target as Node)) return
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', close)
|
||||||
|
return () => document.removeEventListener('mousedown', close)
|
||||||
|
}, [showDropdown])
|
||||||
|
|
||||||
|
const memberIds = members.map(m => m.user_id)
|
||||||
|
|
||||||
|
const toggleMember = (userId: number) => {
|
||||||
|
const newIds = memberIds.includes(userId)
|
||||||
|
? memberIds.filter(id => id !== userId)
|
||||||
|
: [...memberIds, userId]
|
||||||
|
onSetMembers(newIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
{members.map(m => (
|
||||||
|
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||||
|
paid={!!m.paid}
|
||||||
|
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<button ref={btnRef} onClick={openDropdown}
|
||||||
|
style={{
|
||||||
|
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||||
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showDropdown && ReactDOM.createPortal(
|
||||||
|
<div ref={dropRef} style={{
|
||||||
|
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||||
|
}}>
|
||||||
|
{tripMembers.map(tm => {
|
||||||
|
const isActive = memberIds.includes(tm.id)
|
||||||
|
return (
|
||||||
|
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||||
|
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||||
|
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{tm.avatar_url
|
||||||
|
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: tm.username?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||||
|
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { budgetApi } from '../../api/client'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers'
|
||||||
|
import RingAvatar from './BudgetPanelRingAvatar'
|
||||||
|
|
||||||
|
interface PerPersonSummaryEntry {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
total_assigned: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerPersonInlineProps {
|
||||||
|
tripId: number
|
||||||
|
budgetItems: BudgetItem[]
|
||||||
|
currency: string
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
|
||||||
|
const [data, setData] = useState<PerPersonSummaryEntry[] | null>(null)
|
||||||
|
const fmt = (v: number) => fmtNum(v, locale, currency)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||||
|
}, [tripId, budgetItems])
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
|
||||||
|
const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{grandTotal > 0 && (
|
||||||
|
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
|
||||||
|
{people.map(p => (
|
||||||
|
<div key={p.user_id} style={{
|
||||||
|
height: '100%', borderRadius: 999,
|
||||||
|
flex: Math.max(p.total_assigned || 0, 0.01),
|
||||||
|
background: p.color.gradient,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{people.map(p => {
|
||||||
|
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
|
||||||
|
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||||
|
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Wallet } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PieSegment {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||||
|
interface PieChartProps {
|
||||||
|
segments: PieSegment[]
|
||||||
|
size?: number
|
||||||
|
totalLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||||
|
if (!segments.length) return null
|
||||||
|
|
||||||
|
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||||
|
if (total === 0) return null
|
||||||
|
|
||||||
|
let cumDeg = 0
|
||||||
|
const stops = segments.map(seg => {
|
||||||
|
const start = cumDeg
|
||||||
|
const deg = (seg.value / total) * 360
|
||||||
|
cumDeg += deg
|
||||||
|
return `${seg.color} ${start}deg ${start + deg}deg`
|
||||||
|
}).join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||||
|
<div
|
||||||
|
className="trek-pie-reveal"
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%',
|
||||||
|
background: `conic-gradient(${stops})`,
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '50%', left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: size * 0.55, height: size * 0.55,
|
||||||
|
borderRadius: '50%', background: 'var(--bg-card)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||||
|
}}>
|
||||||
|
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { colorForUserId } from './BudgetPanel.helpers'
|
||||||
|
|
||||||
|
export default function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
|
||||||
|
const color = colorForUserId(userId)
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||||
|
padding: 2, background: color.gradient,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%', borderRadius: '50%',
|
||||||
|
background: innerBg,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||||
|
}}>
|
||||||
|
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
|
import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
import { SYMBOLS } from './BudgetPanel.constants'
|
||||||
|
import { hexLighten, widgetTheme } from './BudgetPanel.helpers'
|
||||||
|
import RingAvatar from './BudgetPanelRingAvatar'
|
||||||
|
import PerPersonInline from './BudgetPanelPerPersonInline'
|
||||||
|
import type { SettlementData, PieSegment } from './useBudgetPanel'
|
||||||
|
|
||||||
|
interface BudgetSummaryProps {
|
||||||
|
theme: ReturnType<typeof widgetTheme>
|
||||||
|
currency: string
|
||||||
|
locale: string
|
||||||
|
grandTotal: number
|
||||||
|
hasMultipleMembers: boolean
|
||||||
|
budgetItems: BudgetItem[]
|
||||||
|
settlement: SettlementData | null
|
||||||
|
settlementOpen: boolean
|
||||||
|
setSettlementOpen: Dispatch<SetStateAction<boolean>>
|
||||||
|
pieSegments: PieSegment[]
|
||||||
|
isDark: boolean
|
||||||
|
tripId: number
|
||||||
|
t: (key: string) => string
|
||||||
|
fmt: (v: number | null | undefined, cur: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems,
|
||||||
|
settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: theme.bg,
|
||||||
|
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: theme.shadow,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: 12,
|
||||||
|
background: theme.iconBg,
|
||||||
|
border: `1px solid ${theme.iconBorder}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: theme.iconColor, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Wallet size={20} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const decimals = currencyDecimals(currency)
|
||||||
|
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||||
|
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||||
|
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
|
||||||
|
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||||
|
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>{currency}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && (
|
||||||
|
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settlement dropdown inside the total card */}
|
||||||
|
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||||
|
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||||
|
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||||
|
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||||
|
}}>
|
||||||
|
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
|
{t('budget.settlement')}
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||||
|
<span style={{ display: 'flex', cursor: 'help' }}
|
||||||
|
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||||
|
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Info size={11} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<div style={{
|
||||||
|
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||||
|
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
{t('budget.settlementInfo')}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{settlementOpen && (
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{settlement.flows.map((flow, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '12px 14px', borderRadius: 14,
|
||||||
|
background: theme.flowBg,
|
||||||
|
border: `1px solid ${theme.flowBorder}`,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
|
||||||
|
>
|
||||||
|
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||||
|
{fmt(flow.amount, currency)}
|
||||||
|
</span>
|
||||||
|
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||||
|
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||||
|
{t('budget.netBalances')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
|
||||||
|
const positive = b.balance > 0
|
||||||
|
const Trend = positive ? TrendingUp : TrendingDown
|
||||||
|
return (
|
||||||
|
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
|
||||||
|
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{b.username}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '4px 10px', borderRadius: 8,
|
||||||
|
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
|
||||||
|
color: positive ? '#10b981' : '#ef4444',
|
||||||
|
}}>
|
||||||
|
<Trend size={11} strokeWidth={3} />
|
||||||
|
{positive ? '+' : ''}{fmt(b.balance, currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pieSegments.length > 0 && (() => {
|
||||||
|
const decimals = currencyDecimals(currency)
|
||||||
|
const total = pieSegments.reduce((s, x) => s + x.value, 0)
|
||||||
|
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||||
|
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
|
||||||
|
const R = 80
|
||||||
|
const CIRC = 2 * Math.PI * R
|
||||||
|
let dashOffset = 0
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: theme.bg,
|
||||||
|
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: theme.shadow,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 11,
|
||||||
|
background: theme.iconBg,
|
||||||
|
border: `1px solid ${theme.iconBorder}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: theme.iconColor, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<PieChartIcon size={18} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
|
||||||
|
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
|
||||||
|
<defs>
|
||||||
|
{pieSegments.map((seg, i) => {
|
||||||
|
const c2 = hexLighten(seg.color, 0.2)
|
||||||
|
return (
|
||||||
|
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={seg.color} />
|
||||||
|
<stop offset="100%" stopColor={c2} />
|
||||||
|
</linearGradient>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</defs>
|
||||||
|
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
|
||||||
|
{pieSegments.map((seg, i) => {
|
||||||
|
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
|
||||||
|
const circle = (
|
||||||
|
<circle key={i}
|
||||||
|
cx={100} cy={100} r={R}
|
||||||
|
fill="none" strokeLinecap="round" strokeWidth={22}
|
||||||
|
stroke={`url(#cat-grad-${i})`}
|
||||||
|
strokeDasharray={`${segLen} ${CIRC}`}
|
||||||
|
strokeDashoffset={-dashOffset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
dashOffset += segLen
|
||||||
|
return circle
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
|
||||||
|
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||||
|
<span>{totalInt}</span>
|
||||||
|
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{pieSegments.map((seg, i) => {
|
||||||
|
const pct = total > 0 ? (seg.value / total) * 100 : 0
|
||||||
|
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
|
||||||
|
const c2 = hexLighten(seg.color, 0.2)
|
||||||
|
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
|
||||||
|
return (
|
||||||
|
<div key={seg.name} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '10px 8px', borderRadius: 12,
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
|
||||||
|
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
|
||||||
|
boxShadow: `0 0 12px ${seg.color}80`,
|
||||||
|
}} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '4px 9px', borderRadius: 7,
|
||||||
|
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
background: `${seg.color}26`,
|
||||||
|
border: `1px solid ${seg.color}40`,
|
||||||
|
color: chipColor,
|
||||||
|
}}>{pctLabel}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { budgetApi } from '../../api/client'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||||
|
import { PIE_COLORS } from './BudgetPanel.constants'
|
||||||
|
import type { TripMember } from './BudgetPanelMemberChips'
|
||||||
|
|
||||||
|
function useIsDark(): boolean {
|
||||||
|
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
|
||||||
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
return () => mo.disconnect()
|
||||||
|
}, [])
|
||||||
|
return dark
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditingCat {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettlementPerson {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettlementFlow {
|
||||||
|
from: SettlementPerson
|
||||||
|
to: SettlementPerson
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettlementBalance {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
balance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettlementData {
|
||||||
|
balances: SettlementBalance[]
|
||||||
|
flows: SettlementFlow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PieSegment {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddItemData {
|
||||||
|
name: string
|
||||||
|
total_price: number
|
||||||
|
persons: number | null
|
||||||
|
days: number | null
|
||||||
|
note: string | null
|
||||||
|
expense_date: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) {
|
||||||
|
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||||
|
const can = useCanDo()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const isDark = useIsDark()
|
||||||
|
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
|
const [editingCat, setEditingCat] = useState<EditingCat | null>(null) // { name, value }
|
||||||
|
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||||
|
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||||
|
const currency = trip?.currency || 'EUR'
|
||||||
|
const canEdit = can('budget_edit', trip)
|
||||||
|
|
||||||
|
const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur)
|
||||||
|
const hasMultipleMembers = tripMembers.length > 1
|
||||||
|
|
||||||
|
// Drag state for categories
|
||||||
|
const [dragCat, setDragCat] = useState<string | null>(null)
|
||||||
|
const [dragOverCat, setDragOverCat] = useState<string | null>(null)
|
||||||
|
// Drag state for items within a category
|
||||||
|
const [dragItem, setDragItem] = useState<number | null>(null)
|
||||||
|
const [dragOverItem, setDragOverItem] = useState<number | null>(null)
|
||||||
|
const [dragItemCat, setDragItemCat] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load settlement data whenever budget items change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMultipleMembers) return
|
||||||
|
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||||
|
}, [tripId, budgetItems, hasMultipleMembers])
|
||||||
|
|
||||||
|
const setCurrency = (cur: string) => {
|
||||||
|
if (tripId) updateTrip(tripId, { currency: cur })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, BudgetItem[]>()
|
||||||
|
for (const item of (budgetItems || [])) {
|
||||||
|
const cat = item.category || 'Other'
|
||||||
|
if (!map.has(cat)) map.set(cat, [])
|
||||||
|
map.get(cat)!.push(item)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [budgetItems])
|
||||||
|
|
||||||
|
const categoryNames = Array.from(grouped.keys())
|
||||||
|
|
||||||
|
// Stable color mapping: assign index-based colors once, never reassign on reorder
|
||||||
|
const colorMapRef = useRef(new Map<string, string>())
|
||||||
|
const categoryColor = useCallback((cat: string) => {
|
||||||
|
const map = colorMapRef.current
|
||||||
|
if (!map.has(cat)) {
|
||||||
|
map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
|
||||||
|
}
|
||||||
|
return map.get(cat)!
|
||||||
|
}, [])
|
||||||
|
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
|
||||||
|
|
||||||
|
const pieSegments = useMemo<PieSegment[]>(() =>
|
||||||
|
categoryNames.map((cat, i) => ({
|
||||||
|
name: cat,
|
||||||
|
value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
|
||||||
|
color: categoryColor(cat),
|
||||||
|
})).filter(s => s.value > 0)
|
||||||
|
, [grouped, categoryNames])
|
||||||
|
|
||||||
|
const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
|
||||||
|
const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial<BudgetItem>) } catch { toast.error(t('common.error')) } }
|
||||||
|
const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
|
||||||
|
const handleDeleteCategory = async (cat: string) => {
|
||||||
|
const items = grouped.get(cat) || []
|
||||||
|
try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
|
||||||
|
catch { toast.error(t('common.error')) }
|
||||||
|
}
|
||||||
|
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||||
|
if (!newName.trim() || newName.trim() === oldName) return
|
||||||
|
const items = grouped.get(oldName) || []
|
||||||
|
try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
|
||||||
|
catch { toast.error(t('common.error')) }
|
||||||
|
}
|
||||||
|
const handleAddCategory = () => {
|
||||||
|
if (!newCategoryName.trim()) return
|
||||||
|
Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
|
||||||
|
.catch(() => toast.error(t('common.error')))
|
||||||
|
setNewCategoryName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
const sep = ';'
|
||||||
|
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||||
|
const d = currencyDecimals(currency)
|
||||||
|
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||||
|
|
||||||
|
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
|
||||||
|
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||||
|
const rows = [header.join(sep)]
|
||||||
|
|
||||||
|
for (const cat of categoryNames) {
|
||||||
|
for (const item of (grouped.get(cat) || [])) {
|
||||||
|
const pp = calcPP(item.total_price, item.persons)
|
||||||
|
const pd = calcPD(item.total_price, item.days)
|
||||||
|
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||||
|
rows.push([
|
||||||
|
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
|
||||||
|
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
|
||||||
|
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
|
||||||
|
esc(item.note || ''),
|
||||||
|
].join(sep))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bom = ''
|
||||||
|
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim()
|
||||||
|
a.download = `budget-${safeName}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||||
|
const td: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||||
|
|
||||||
|
return {
|
||||||
|
trip, budgetItems,
|
||||||
|
setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
|
||||||
|
t, locale, isDark, theme,
|
||||||
|
newCategoryName, setNewCategoryName,
|
||||||
|
editingCat, setEditingCat,
|
||||||
|
settlement, settlementOpen, setSettlementOpen,
|
||||||
|
currency, canEdit, fmt, hasMultipleMembers,
|
||||||
|
dragCat, setDragCat, dragOverCat, setDragOverCat,
|
||||||
|
dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
|
||||||
|
setCurrency,
|
||||||
|
grouped, categoryNames, categoryColor, grandTotal, pieSegments,
|
||||||
|
handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
|
||||||
|
th, td,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const EMOJI_CATEGORIES = {
|
||||||
|
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||||
|
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||||
|
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction Quick Menu (right-click)
|
||||||
|
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||||
|
|
||||||
|
export const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||||
|
export function emojiToCodepoint(emoji) {
|
||||||
|
const codepoints = []
|
||||||
|
for (const c of emoji) {
|
||||||
|
const cp = c.codePointAt(0)
|
||||||
|
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||||
|
}
|
||||||
|
return codepoints.join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||||
|
export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||||
|
|
||||||
|
export function formatTime(isoString, is12h) {
|
||||||
|
const d = parseUTC(isoString)
|
||||||
|
const h = d.getHours()
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
if (is12h) {
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||||
|
return `${h12}:${mm} ${period}`
|
||||||
|
}
|
||||||
|
return `${String(h).padStart(2, '0')}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateSeparator(isoString, t) {
|
||||||
|
const d = parseUTC(isoString)
|
||||||
|
const now = new Date()
|
||||||
|
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||||
|
|
||||||
|
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||||
|
|
||||||
|
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowDateSeparator(msg, prevMsg) {
|
||||||
|
if (!prevMsg) return true
|
||||||
|
const d1 = parseUTC(msg.created_at).toDateString()
|
||||||
|
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||||
|
return d1 !== d2
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,348 +1,10 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
|
||||||
import { collabApi } from '../../api/client'
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
|
import { useCollabChat } from './useCollabChat'
|
||||||
interface ChatReaction {
|
import { ChatMessages } from './CollabChatMessages'
|
||||||
emoji: string
|
import { EmojiPicker } from './CollabChatEmojiPicker'
|
||||||
count: number
|
import { ReactionMenu } from './CollabChatReactionMenu'
|
||||||
users: { id: number; username: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatMessage {
|
|
||||||
id: number
|
|
||||||
trip_id: number
|
|
||||||
user_id: number
|
|
||||||
text: string
|
|
||||||
reply_to_id: number | null
|
|
||||||
reactions: ChatReaction[]
|
|
||||||
created_at: string
|
|
||||||
user?: { username: string; avatar_url: string | null }
|
|
||||||
reply_to?: ChatMessage | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
|
||||||
function emojiToCodepoint(emoji) {
|
|
||||||
const codepoints = []
|
|
||||||
for (const c of emoji) {
|
|
||||||
const cp = c.codePointAt(0)
|
|
||||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
|
||||||
}
|
|
||||||
return codepoints.join('-')
|
|
||||||
}
|
|
||||||
|
|
||||||
function TwemojiImg({ emoji, size = 20, style = {} }) {
|
|
||||||
const cp = emojiToCodepoint(emoji)
|
|
||||||
const [failed, setFailed] = useState(false)
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
|
||||||
alt={emoji}
|
|
||||||
draggable={false}
|
|
||||||
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMOJI_CATEGORIES = {
|
|
||||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
|
||||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
|
||||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
|
||||||
function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
|
||||||
|
|
||||||
function formatTime(isoString, is12h) {
|
|
||||||
const d = parseUTC(isoString)
|
|
||||||
const h = d.getHours()
|
|
||||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
||||||
if (is12h) {
|
|
||||||
const period = h >= 12 ? 'PM' : 'AM'
|
|
||||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
|
||||||
return `${h12}:${mm} ${period}`
|
|
||||||
}
|
|
||||||
return `${String(h).padStart(2, '0')}:${mm}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateSeparator(isoString, t) {
|
|
||||||
const d = parseUTC(isoString)
|
|
||||||
const now = new Date()
|
|
||||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
|
||||||
|
|
||||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
|
||||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
|
||||||
|
|
||||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldShowDateSeparator(msg, prevMsg) {
|
|
||||||
if (!prevMsg) return true
|
|
||||||
const d1 = parseUTC(msg.created_at).toDateString()
|
|
||||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
|
||||||
return d1 !== d2
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Emoji Picker ── */
|
|
||||||
interface EmojiPickerProps {
|
|
||||||
onSelect: (emoji: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
anchorRef: React.RefObject<HTMLElement | null>
|
|
||||||
containerRef: React.RefObject<HTMLElement | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
|
||||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
|
||||||
const ref = useRef(null)
|
|
||||||
|
|
||||||
const getPos = () => {
|
|
||||||
const container = containerRef?.current
|
|
||||||
const anchor = anchorRef?.current
|
|
||||||
if (container && anchor) {
|
|
||||||
const cRect = container.getBoundingClientRect()
|
|
||||||
const aRect = anchor.getBoundingClientRect()
|
|
||||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
|
||||||
}
|
|
||||||
return { bottom: 80, left: 0 }
|
|
||||||
}
|
|
||||||
const pos = getPos()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const close = (e) => {
|
|
||||||
if (ref.current && ref.current.contains(e.target)) return
|
|
||||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', close)
|
|
||||||
return () => document.removeEventListener('mousedown', close)
|
|
||||||
}, [onClose, anchorRef])
|
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
|
||||||
<div ref={ref} style={{
|
|
||||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* Category tabs */}
|
|
||||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
|
||||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
|
||||||
<button key={c} onClick={() => setCat(c)} style={{
|
|
||||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
|
||||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
|
||||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
{c}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Emoji grid */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
|
||||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
|
||||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
|
||||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
|
||||||
padding: 2, transition: 'transform 0.1s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
|
||||||
>
|
|
||||||
<TwemojiImg emoji={emoji} size={20} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reaction Quick Menu (right-click) ── */
|
|
||||||
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
|
||||||
|
|
||||||
interface ReactionMenuProps {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
onReact: (emoji: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
|
||||||
const ref = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
|
|
||||||
document.addEventListener('mousedown', close)
|
|
||||||
return () => document.removeEventListener('mousedown', close)
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
// Clamp to viewport
|
|
||||||
const menuWidth = 156
|
|
||||||
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} style={{
|
|
||||||
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
|
||||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
|
||||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
|
||||||
}}>
|
|
||||||
{QUICK_REACTIONS.map(emoji => (
|
|
||||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
|
||||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
|
||||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
|
||||||
>
|
|
||||||
<TwemojiImg emoji={emoji} size={18} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Message Text with clickable URLs ── */
|
|
||||||
interface MessageTextProps {
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageText({ text }: MessageTextProps) {
|
|
||||||
const parts = text.split(URL_REGEX)
|
|
||||||
const urls = text.match(URL_REGEX) || []
|
|
||||||
const result = []
|
|
||||||
parts.forEach((part, i) => {
|
|
||||||
if (part) result.push(part)
|
|
||||||
if (urls[i]) result.push(
|
|
||||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
|
||||||
{urls[i]}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return <>{result}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Link Preview ── */
|
|
||||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
|
||||||
const previewCache = {}
|
|
||||||
|
|
||||||
interface LinkPreviewProps {
|
|
||||||
url: string
|
|
||||||
tripId: number
|
|
||||||
own: boolean
|
|
||||||
onLoad: (() => void) | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
|
||||||
const [data, setData] = useState(previewCache[url] || null)
|
|
||||||
const [loading, setLoading] = useState(!previewCache[url])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (previewCache[url]) return
|
|
||||||
collabApi.linkPreview(tripId, url).then(d => {
|
|
||||||
previewCache[url] = d
|
|
||||||
setData(d)
|
|
||||||
setLoading(false)
|
|
||||||
if (d?.title || d?.description || d?.image) onLoad?.()
|
|
||||||
}).catch(() => setLoading(false))
|
|
||||||
}, [url, tripId])
|
|
||||||
|
|
||||||
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
|
||||||
|
|
||||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
|
||||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
|
||||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
|
||||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
|
||||||
maxWidth: 280, transition: 'opacity 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
|
||||||
>
|
|
||||||
{data.image && (
|
|
||||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
|
||||||
onError={e => e.target.style.display = 'none'} />
|
|
||||||
)}
|
|
||||||
<div style={{ padding: '8px 10px' }}>
|
|
||||||
{domain && (
|
|
||||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
|
||||||
{data.site_name || domain}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.title && (
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
||||||
{data.title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{data.description && (
|
|
||||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
||||||
{data.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
|
||||||
interface ReactionBadgeProps {
|
|
||||||
reaction: ChatReaction
|
|
||||||
currentUserId: number
|
|
||||||
onReact: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
|
|
||||||
const [hover, setHover] = useState(false)
|
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
||||||
const ref = useRef(null)
|
|
||||||
const names = reaction.users.map(u => u.username).join(', ')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button ref={ref} onClick={onReact}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (ref.current) {
|
|
||||||
const rect = ref.current.getBoundingClientRect()
|
|
||||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
|
||||||
}
|
|
||||||
setHover(true)
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setHover(false)}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
|
||||||
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
background: 'transparent', transition: 'transform 0.1s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
|
||||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
|
||||||
</button>
|
|
||||||
{hover && names && ReactDOM.createPortal(
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
|
||||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
|
||||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
|
||||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
|
||||||
}}>
|
|
||||||
{names}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main Component ── */
|
/* ── Main Component ── */
|
||||||
interface CollabChatProps {
|
interface CollabChatProps {
|
||||||
@@ -351,164 +13,8 @@ interface CollabChatProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||||
const { t } = useTranslation()
|
const S = useCollabChat(tripId, currentUser)
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||||
|
|
||||||
const [messages, setMessages] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [hasMore, setHasMore] = useState(false)
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
const [replyTo, setReplyTo] = useState(null)
|
|
||||||
const [hoveredId, setHoveredId] = useState(null)
|
|
||||||
const [sending, setSending] = useState(false)
|
|
||||||
const [showEmoji, setShowEmoji] = useState(false)
|
|
||||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
|
||||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
|
||||||
|
|
||||||
const containerRef = useRef(null)
|
|
||||||
const messagesRef = useRef(messages)
|
|
||||||
messagesRef.current = messages
|
|
||||||
const scrollRef = useRef(null)
|
|
||||||
const textareaRef = useRef(null)
|
|
||||||
const emojiBtnRef = useRef(null)
|
|
||||||
const isAtBottom = useRef(true)
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
|
||||||
const el = scrollRef.current
|
|
||||||
if (!el) return
|
|
||||||
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const checkAtBottom = useCallback(() => {
|
|
||||||
const el = scrollRef.current
|
|
||||||
if (!el) return
|
|
||||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── load messages ── */
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
setLoading(true)
|
|
||||||
collabApi.getMessages(tripId).then(data => {
|
|
||||||
if (cancelled) return
|
|
||||||
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
|
||||||
setMessages(msgs)
|
|
||||||
setHasMore(msgs.length >= 100)
|
|
||||||
setLoading(false)
|
|
||||||
setTimeout(() => scrollToBottom(), 30)
|
|
||||||
}).catch(() => { if (!cancelled) setLoading(false) })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [tripId, scrollToBottom])
|
|
||||||
|
|
||||||
/* ── load more ── */
|
|
||||||
const handleLoadMore = useCallback(async () => {
|
|
||||||
if (loadingMore || messages.length === 0) return
|
|
||||||
setLoadingMore(true)
|
|
||||||
const el = scrollRef.current
|
|
||||||
const prevHeight = el ? el.scrollHeight : 0
|
|
||||||
try {
|
|
||||||
const data = await collabApi.getMessages(tripId, messages[0]?.id)
|
|
||||||
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
|
||||||
if (older.length === 0) { setHasMore(false) }
|
|
||||||
else {
|
|
||||||
setMessages(prev => [...older, ...prev])
|
|
||||||
setHasMore(older.length >= 100)
|
|
||||||
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
|
|
||||||
}
|
|
||||||
} catch {} finally { setLoadingMore(false) }
|
|
||||||
}, [tripId, loadingMore, messages])
|
|
||||||
|
|
||||||
/* ── websocket ── */
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (event) => {
|
|
||||||
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
|
|
||||||
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
|
|
||||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
|
|
||||||
}
|
|
||||||
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
|
|
||||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
|
|
||||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
|
|
||||||
}
|
|
||||||
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
|
|
||||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addListener(handler)
|
|
||||||
return () => removeListener(handler)
|
|
||||||
}, [tripId, scrollToBottom])
|
|
||||||
|
|
||||||
/* ── auto-resize textarea ── */
|
|
||||||
const handleTextChange = useCallback((e) => {
|
|
||||||
setText(e.target.value)
|
|
||||||
const ta = textareaRef.current
|
|
||||||
if (ta) {
|
|
||||||
ta.style.height = 'auto'
|
|
||||||
const h = Math.min(ta.scrollHeight, 100)
|
|
||||||
ta.style.height = h + 'px'
|
|
||||||
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── send ── */
|
|
||||||
const handleSend = useCallback(async () => {
|
|
||||||
const body = text.trim()
|
|
||||||
if (!body || sending) return
|
|
||||||
setSending(true)
|
|
||||||
try {
|
|
||||||
const payload = { text: body }
|
|
||||||
if (replyTo) payload.reply_to = replyTo.id
|
|
||||||
const data = await collabApi.sendMessage(tripId, payload)
|
|
||||||
if (data?.message) {
|
|
||||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
|
||||||
}
|
|
||||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
|
||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
|
||||||
isAtBottom.current = true
|
|
||||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
|
||||||
} catch {} finally { setSending(false) }
|
|
||||||
}, [text, sending, replyTo, tripId, scrollToBottom])
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
|
||||||
}, [handleSend])
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async (msgId) => {
|
|
||||||
const msg = messages.find(m => m.id === msgId)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
|
||||||
})
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await collabApi.deleteMessage(tripId, msgId)
|
|
||||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
|
||||||
} catch {}
|
|
||||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
|
||||||
}, 400)
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
const handleReact = useCallback(async (msgId, emoji) => {
|
|
||||||
setReactMenu(null)
|
|
||||||
try {
|
|
||||||
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
|
||||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
|
||||||
} catch {}
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
const handleEmojiSelect = useCallback((emoji) => {
|
|
||||||
setText(prev => prev + emoji)
|
|
||||||
textareaRef.current?.focus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
|
|
||||||
|
|
||||||
// Check if message is only emoji (1-3 emojis, no other text)
|
|
||||||
const isEmojiOnly = (text) => {
|
|
||||||
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u
|
|
||||||
return emojiRegex.test(text.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Loading ── */
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -517,247 +23,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main ── */
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||||
{/* Messages */}
|
<ChatMessages {...S} />
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
|
||||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
|
||||||
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
|
||||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
|
||||||
display: 'flex', flexDirection: 'column', gap: 1,
|
|
||||||
}}>
|
|
||||||
{hasMore && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
|
||||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
|
||||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
|
||||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
<ChevronUp size={13} />
|
|
||||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{messages.map((msg, idx) => {
|
|
||||||
const own = isOwn(msg)
|
|
||||||
const prevMsg = messages[idx - 1]
|
|
||||||
const nextMsg = messages[idx + 1]
|
|
||||||
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
|
|
||||||
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
|
|
||||||
const showDate = shouldShowDateSeparator(msg, prevMsg)
|
|
||||||
const showAvatar = !own && isLastInGroup
|
|
||||||
const bigEmoji = isEmojiOnly(msg.text)
|
|
||||||
const hasReply = msg.reply_text || msg.reply_to
|
|
||||||
// Deleted message placeholder
|
|
||||||
if (msg._deleted) {
|
|
||||||
return (
|
|
||||||
<React.Fragment key={msg.id}>
|
|
||||||
{showDate && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
|
||||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
|
||||||
{formatDateSeparator(msg.created_at, t)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
|
||||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bubble border radius — iMessage style tails
|
|
||||||
const br = own
|
|
||||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
|
||||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={msg.id}>
|
|
||||||
{/* Date separator */}
|
|
||||||
{showDate && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
|
||||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
|
||||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
{formatDateSeparator(msg.created_at, t)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
|
||||||
flexDirection: own ? 'row-reverse' : 'row',
|
|
||||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
|
||||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
|
||||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
|
||||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
|
||||||
}}>
|
|
||||||
{/* Avatar slot for others */}
|
|
||||||
{!own && (
|
|
||||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
|
||||||
{showAvatar && (
|
|
||||||
msg.user_avatar ? (
|
|
||||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{(msg.username || '?')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
|
||||||
{/* Username for others at group start */}
|
|
||||||
{!own && isNewGroup && (
|
|
||||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
|
||||||
{msg.username}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bubble */}
|
|
||||||
<div
|
|
||||||
style={{ position: 'relative' }}
|
|
||||||
onMouseEnter={() => setHoveredId(msg.id)}
|
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
|
||||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
|
||||||
onTouchEnd={e => {
|
|
||||||
const now = Date.now()
|
|
||||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
|
||||||
if (now - lastTap < 300) {
|
|
||||||
e.preventDefault()
|
|
||||||
const touch = e.changedTouches?.[0]
|
|
||||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
|
||||||
}
|
|
||||||
e.currentTarget.dataset.lastTap = now
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{bigEmoji ? (
|
|
||||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
|
||||||
{msg.text}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
|
||||||
color: own ? '#fff' : 'var(--text-primary)',
|
|
||||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
|
||||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
|
||||||
}}>
|
|
||||||
{/* Inline reply quote */}
|
|
||||||
{hasReply && (
|
|
||||||
<div style={{
|
|
||||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
|
||||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
|
||||||
fontSize: 12, lineHeight: 1.3,
|
|
||||||
}}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
|
||||||
{msg.reply_username || ''}
|
|
||||||
</div>
|
|
||||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{(msg.reply_text || '').slice(0, 80)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasReply ? (
|
|
||||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
|
||||||
) : <MessageText text={msg.text} />}
|
|
||||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
|
||||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hover actions */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: -14,
|
|
||||||
display: 'flex', gap: 2,
|
|
||||||
opacity: hoveredId === msg.id ? 1 : 0,
|
|
||||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
|
||||||
transition: 'opacity .1s',
|
|
||||||
...(own ? { left: -6 } : { right: -6 }),
|
|
||||||
}}>
|
|
||||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
|
||||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
|
||||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
|
||||||
>
|
|
||||||
<Reply size={11} />
|
|
||||||
</button>
|
|
||||||
{own && (
|
|
||||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
|
||||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
|
||||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
|
||||||
>
|
|
||||||
<Trash2 size={11} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reactions — iMessage style floating badge */}
|
|
||||||
{msg.reactions?.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
|
||||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
|
||||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
|
||||||
position: 'relative', zIndex: 1,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
|
||||||
borderRadius: 99, background: 'var(--bg-card)',
|
|
||||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
|
||||||
}}>
|
|
||||||
{msg.reactions.map(r => {
|
|
||||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
|
||||||
return (
|
|
||||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp — only on last message of group */}
|
|
||||||
{isLastInGroup && (
|
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
|
||||||
{formatTime(msg.created_at, is12h)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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)' }} className="pb-3 bg-surface-card">
|
||||||
{/* Reply preview */}
|
{/* Reply preview */}
|
||||||
{replyTo && (
|
{replyTo && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -780,23 +50,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 +79,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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface ChatReaction {
|
||||||
|
emoji: string
|
||||||
|
count: number
|
||||||
|
users: { id: number; username: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: number
|
||||||
|
trip_id: number
|
||||||
|
user_id: number
|
||||||
|
text: string
|
||||||
|
reply_to_id: number | null
|
||||||
|
reactions: ChatReaction[]
|
||||||
|
created_at: string
|
||||||
|
user?: { username: string; avatar_url: string | null }
|
||||||
|
reply_to?: ChatMessage | null
|
||||||
|
}
|
||||||