mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
539 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 249ab217f8 | |||
| 499097fa3c | |||
| 002ea91be8 | |||
| 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 | |||
| 4105abcd0f |
@@ -30,3 +30,7 @@ sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# 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
|
||||
@@ -14,7 +13,6 @@
|
||||
*.yaml text eol=lf
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
|
||||
@@ -12,6 +12,8 @@ body:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
## Description
|
||||
<!-- What does this PR do? Why? -->
|
||||
|
||||
## Related Issue or Discussion
|
||||
<!-- This project requires an issue or an approved feature request before submitting a PR. -->
|
||||
<!-- For bug fixes: Closes #ISSUE_NUMBER -->
|
||||
<!-- For features: Addresses discussion #DISCUSSION_NUMBER -->
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] This PR targets the `dev` branch, not `main`
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have updated documentation if needed
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Close issues with unchanged bad titles
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const badTitles = [
|
||||
"[bug]", "bug report", "bug", "issue",
|
||||
"help", "question", "test", "...", "untitled"
|
||||
];
|
||||
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'invalid-title',
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const issue of issues) {
|
||||
const createdAt = new Date(issue.created_at);
|
||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||
|
||||
const titleLower = issue.title.trim().toLowerCase();
|
||||
|
||||
if (!badTitles.includes(titleLower)) {
|
||||
// Title was fixed — remove the label and move on
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: 'invalid-title',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still a bad title after 24h — close it
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
'## Issue closed',
|
||||
'',
|
||||
'This issue has been automatically closed because the title was not updated within 24 hours.',
|
||||
'',
|
||||
'Feel free to open a new issue with a descriptive title that summarizes the problem.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Close PRs with unchanged wrong base branch
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const pull of pulls) {
|
||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||
if (!hasLabel) continue;
|
||||
|
||||
const createdAt = new Date(pull.created_at);
|
||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||
|
||||
// Base was fixed — remove label and move on
|
||||
if (pull.base.ref !== 'main') {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still targeting main after 24h — close it
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
body: [
|
||||
'## PR closed',
|
||||
'',
|
||||
'This PR has been automatically closed because the base branch was not updated to `dev` within 24 hours.',
|
||||
'',
|
||||
'Feel free to open a new PR targeting `dev`.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pull.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Close untitled issues
|
||||
name: Flag issues with bad titles
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -10,58 +10,83 @@ permissions:
|
||||
jobs:
|
||||
check-title:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close if title is empty or generic
|
||||
- name: Flag or redirect issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
const badTitles = [
|
||||
"[bug]",
|
||||
"bug report",
|
||||
"bug",
|
||||
"issue",
|
||||
];
|
||||
|
||||
const featureRequestTitles = [
|
||||
"feature request",
|
||||
"[feature]",
|
||||
"[feature request]",
|
||||
"[enhancement]"
|
||||
]
|
||||
|
||||
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: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
|
||||
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'),
|
||||
});
|
||||
|
||||
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"
|
||||
});
|
||||
} 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: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
|
||||
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"
|
||||
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
|
||||
@@ -6,11 +6,27 @@ on:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/wiki.yml'
|
||||
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:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -20,48 +36,79 @@ jobs:
|
||||
- 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: |
|
||||
# Check if this push is a merge commit from dev branch
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s)
|
||||
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
|
||||
git fetch --tags
|
||||
|
||||
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
|
||||
# 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 [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
|
||||
BUMP="minor"
|
||||
else
|
||||
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"
|
||||
|
||||
# Read current version
|
||||
CURRENT=$(node -p "require('./server/package.json').version")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
|
||||
if [ "$BUMP" = "minor" ]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$CURRENT → $NEW_VERSION ($BUMP)"
|
||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||
|
||||
# Update both package.json files
|
||||
# Update package.json files and Helm chart
|
||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
|
||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
@@ -100,6 +147,8 @@ jobs:
|
||||
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: |
|
||||
@@ -140,14 +189,29 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION=${{ needs.version-bump.outputs.version }}
|
||||
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:$VERSION \
|
||||
-t mauriceboe/nomad:latest \
|
||||
-t mauriceboe/nomad:$VERSION \
|
||||
-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,104 @@
|
||||
name: Enforce PR Target Branch
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Flag or clear wrong base branch
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const base = context.payload.pull_request.base.ref;
|
||||
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// If the base was fixed, remove the label and let it through
|
||||
if (base !== 'main') {
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Base is main — check if this user is a maintainer
|
||||
let permission = 'none';
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.payload.pull_request.user.login,
|
||||
});
|
||||
permission = data.permission;
|
||||
} catch (_) {
|
||||
// User is not a collaborator — treat as 'none'
|
||||
}
|
||||
|
||||
if (['admin', 'write'].includes(permission)) {
|
||||
console.log(`User has '${permission}' permission, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Already labeled — avoid spamming on every push
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
core.setFailed("PR must target `dev`, not `main`.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the label exists
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'wrong-base-branch',
|
||||
color: 'd73a4a',
|
||||
description: 'PR is targeting the wrong base branch',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['wrong-base-branch'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: [
|
||||
'## Wrong target branch',
|
||||
'',
|
||||
'This PR targets `main`, but contributions must go through `dev` first.',
|
||||
'',
|
||||
'To fix this, click **Edit** next to the PR title and change the base branch to `dev`.',
|
||||
'',
|
||||
'**This PR will be automatically closed in 24 hours if the base branch has not been updated.**',
|
||||
'',
|
||||
'> _If you need to merge directly to `main`, contact a maintainer._',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.setFailed("PR must target `dev`, not `main`.");
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
- 'client/**'
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
@@ -34,6 +35,33 @@ jobs:
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage
|
||||
name: backend-coverage
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
|
||||
client-tests:
|
||||
name: Client Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd client && npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: cd client && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: client/coverage/
|
||||
retention-days: 7
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Deploy Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: wiki-deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish to GitHub wiki
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||
with:
|
||||
strategy: clone
|
||||
+5
-2
@@ -16,7 +16,7 @@ client/public/icons/*.png
|
||||
*.sqlite-wal
|
||||
|
||||
# User data
|
||||
server/data/
|
||||
server/data/*
|
||||
server/uploads/
|
||||
|
||||
# Environment
|
||||
@@ -58,4 +58,7 @@ coverage
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
test-data
|
||||
test-data
|
||||
|
||||
.run
|
||||
.full-review
|
||||
+5
-16
@@ -4,11 +4,13 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
|
||||
## 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/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
|
||||
## Pull Requests
|
||||
|
||||
@@ -35,22 +37,9 @@ fix(maps): correct zoom level on Safari
|
||||
feat(budget): add CSV export for expenses
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
|
||||
# Server
|
||||
cd server && npm install && npm run dev
|
||||
|
||||
# Client (separate terminal)
|
||||
cd client && npm install && npm run dev
|
||||
```
|
||||
|
||||
Server: `http://localhost:3001` | Client: `http://localhost:5173`
|
||||
|
||||
On first run, check the server logs for the auto-generated admin credentials.
|
||||
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
|
||||
## More Details
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -9,9 +9,15 @@ structured API.
|
||||
## 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)
|
||||
|
||||
---
|
||||
@@ -21,22 +27,52 @@ structured API.
|
||||
### 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 `403 Forbidden` and the MCP section does not appear in user settings.
|
||||
endpoint returns `404` and the MCP section does not appear in user settings.
|
||||
|
||||
### 2. Create an API token
|
||||
### 2. Connect your MCP client
|
||||
|
||||
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
|
||||
#### Option A: OAuth 2.1 (recommended)
|
||||
|
||||
1. Click **Create New Token**
|
||||
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
|
||||
3. **Copy the token immediately** — it is shown only once and cannot be recovered
|
||||
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:
|
||||
|
||||
Each user can create up to **10 tokens**.
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configure your MCP client
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
|
||||
`claude_desktop_config.json`:
|
||||
**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
|
||||
{
|
||||
@@ -54,7 +90,69 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
|
||||
}
|
||||
```
|
||||
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,11 +165,15 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
|
||||
| **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** | 60 requests per minute per user. Exceeding this returns a `429` error. |
|
||||
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
|
||||
| **Token limits** | Maximum 10 API tokens per user. |
|
||||
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
|
||||
| **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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -80,118 +182,359 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
|
||||
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
|
||||
making changes.
|
||||
|
||||
| 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 |
|
||||
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
|
||||
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
|
||||
| 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 |
|
||||
| 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 |
|
||||
### 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 **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything
|
||||
about a trip in a single call.
|
||||
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 totals, packing stats, reservations, and collab notes. Use this as your context loader. |
|
||||
| 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.** |
|
||||
| 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
|
||||
|
||||
| Tool | Description |
|
||||
|----------------|-----------------------------------------------------------------------------------|
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. |
|
||||
| `update_place` | Update any field of an existing place. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
> 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 |
|
||||
|---------------------------|-------------------------------------------------------------------------------|
|
||||
| `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"). |
|
||||
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
|
||||
| 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
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, 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. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
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
|
||||
|
||||
| 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. |
|
||||
> 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
### Collab Notes
|
||||
### 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 and its associated files. |
|
||||
| `delete_collab_note` | Delete a collab note. |
|
||||
|
||||
### Bucket List
|
||||
### 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 _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|--------------------------------------------------------------------------------|
|
||||
| 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. |
|
||||
| `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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -231,4 +574,4 @@ of everything that was added.
|
||||
|
||||
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
|
||||
|
||||

|
||||

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

|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||
</picture>
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
<picture>
|
||||
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||
</picture>
|
||||
|
||||
<details>
|
||||
<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>
|
||||
|
||||
## Features
|
||||
<br />
|
||||
|
||||
### Trip Planning
|
||||
- **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
|
||||
- **Map Category Filter** — Filter places by category and see only matching pins on the map
|
||||
|
||||
### Travel Management
|
||||
- **Reservations & Bookings** — Track flights, accommodations, 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** — Category-based checklists with user assignment, packing templates, and progress tracking
|
||||
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
|
||||
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
|
||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
||||
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
|
||||
|
||||
### Mobile & PWA
|
||||
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
||||
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
|
||||
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
|
||||
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
|
||||
|
||||
### Collaboration
|
||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
|
||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
|
||||
- **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, bucket list with planned travel dates, 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
|
||||
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
|
||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
|
||||
- **Admin Panel** — User management, invite links, packing templates, 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 + TOTP (MFA)
|
||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||
- **Weather**: Open-Meteo API (free, no key required)
|
||||
- **Icons**: lucide-react
|
||||
|
||||
## Quick Start
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
@@ -104,19 +176,40 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
|
||||
### Install as App (PWA)
|
||||
<div align="center">
|
||||
|
||||
TREK works as a Progressive Web App — no App Store needed:
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
|
||||
1. Open your TREK instance in the browser (HTTPS required)
|
||||
2. **iOS**: Share button → "Add to Home Screen"
|
||||
3. **Android**: Menu → "Install app" or "Add to Home Screen"
|
||||
4. TREK launches fullscreen with its own icon, just like a native app
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## 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>
|
||||
<summary>Docker Compose (recommended for production)</summary>
|
||||
<summary>Full compose example with secure defaults</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -139,28 +232,19 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
|
||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
||||
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||
- APP_URL=${APP_URL:-} # 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 email notifications and other external links
|
||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only)
|
||||
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
||||
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using 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 (resets data hourly)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
- 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:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -173,21 +257,49 @@ services:
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
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>
|
||||
|
||||
### 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
|
||||
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
|
||||
docker pull mauriceboe/trek
|
||||
@@ -195,27 +307,23 @@ docker rm -f trek
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||
```
|
||||
|
||||
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
### Rotating the Encryption Key
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
|
||||
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
|
||||
|
||||
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
|
||||
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
|
||||
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
||||
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>
|
||||
<summary>Nginx</summary>
|
||||
@@ -231,8 +339,20 @@ server {
|
||||
listen 443 ssl http2;
|
||||
server_name trek.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
ssl_certificate /etc/ssl/fullchain.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 {
|
||||
proxy_pass http://localhost:3000;
|
||||
@@ -240,19 +360,8 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -261,17 +370,24 @@ server {
|
||||
<details>
|
||||
<summary>Caddy</summary>
|
||||
|
||||
Caddy handles WebSocket upgrades automatically:
|
||||
|
||||
```
|
||||
```caddy
|
||||
trek.yourdomain.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
</details>
|
||||
|
||||
## Environment Variables
|
||||
<br />
|
||||
|
||||
## Environment variables
|
||||
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
|
||||
<br />
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
@@ -281,56 +397,47 @@ trek.yourdomain.com {
|
||||
| `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` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
|
||||
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
||||
| `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` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
|
||||
| `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 to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial Setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
|
||||
| `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 | `60` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
## Optional API Keys
|
||||
</details>
|
||||
|
||||
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.
|
||||
|
||||
### Google Maps (Place Search & Photos)
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a project and enable the **Places API (New)**
|
||||
3. Create an API key under Credentials
|
||||
4. In TREK: Admin Panel → Settings → Google Maps
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
docker build -t trek .
|
||||
```
|
||||
<br />
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database**: SQLite, stored in `./data/travel.db`
|
||||
- **Uploads**: Stored in `./uploads/`
|
||||
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups**: Create and restore via Admin Panel
|
||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||
- **Database** — SQLite, stored in `./data/travel.db`
|
||||
- **Uploads** — stored in `./uploads/`
|
||||
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups** — create and restore via Admin Panel
|
||||
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||
|
||||
<br />
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
+121
@@ -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).
|
||||
@@ -10,8 +10,20 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- 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 \
|
||||
@@ -32,5 +44,7 @@ See `values.yaml` for more options.
|
||||
- `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.
|
||||
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
|
||||
- `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.
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 0.1.0
|
||||
version: 3.0.9
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "latest"
|
||||
appVersion: "3.0.9"
|
||||
@@ -22,6 +22,9 @@ data:
|
||||
{{- 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 }}
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
tag: latest
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
@@ -19,17 +19,23 @@ env:
|
||||
# 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"
|
||||
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
|
||||
# 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"
|
||||
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
|
||||
# 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"
|
||||
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
|
||||
# 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.
|
||||
@@ -40,7 +46,9 @@ env:
|
||||
# OIDC_DISPLAY_NAME: "SSO"
|
||||
# Label shown on the SSO login button.
|
||||
# OIDC_ONLY: "false"
|
||||
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
|
||||
# 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: ""
|
||||
@@ -51,8 +59,10 @@ env:
|
||||
# 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: "60"
|
||||
# Max MCP API requests per user per minute. Defaults to 60.
|
||||
# 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.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
|
||||
Generated
+3023
-335
File diff suppressed because it is too large
Load Diff
+21
-3
@@ -1,19 +1,27 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.9.5",
|
||||
"version": "3.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.4.1",
|
||||
@@ -22,22 +30,32 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"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",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react": "^18.2.61",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.13.0",
|
||||
"postcss": "^8.4.35",
|
||||
"sharp": "^0.33.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0"
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
+78
-6
@@ -2,7 +2,10 @@ import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
@@ -10,24 +13,36 @@ import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
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 BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
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 {
|
||||
children: ReactNode
|
||||
adminRequired?: boolean
|
||||
addonId?: string
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
|
||||
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 location = useLocation()
|
||||
|
||||
@@ -60,7 +75,16 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
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() {
|
||||
@@ -78,20 +102,34 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/')) {
|
||||
loadUser()
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
// 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(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.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?.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) {
|
||||
@@ -123,9 +161,15 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
registerSyncTriggers()
|
||||
return () => unregisterSyncTriggers()
|
||||
}, [])
|
||||
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
@@ -155,14 +199,26 @@ export default function App() {
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [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 (
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
@@ -219,6 +275,22 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey"
|
||||
element={
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey/:id"
|
||||
element={
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
|
||||
+198
-7
@@ -1,7 +1,36 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import { getSocketId } from './websocket'
|
||||
import en from '../i18n/translations/en'
|
||||
import br from '../i18n/translations/br'
|
||||
import de from '../i18n/translations/de'
|
||||
import es from '../i18n/translations/es'
|
||||
import fr from '../i18n/translations/fr'
|
||||
import it from '../i18n/translations/it'
|
||||
import nl from '../i18n/translations/nl'
|
||||
import pl from '../i18n/translations/pl'
|
||||
import cs from '../i18n/translations/cs'
|
||||
import hu from '../i18n/translations/hu'
|
||||
import ru from '../i18n/translations/ru'
|
||||
import zh from '../i18n/translations/zh'
|
||||
import zhTw from '../i18n/translations/zhTw'
|
||||
import ar from '../i18n/translations/ar'
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
|
||||
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
|
||||
}
|
||||
|
||||
function translateRateLimit(): string {
|
||||
const fallback = 'Too many attempts. Please try again later.'
|
||||
try {
|
||||
const lang = localStorage.getItem('app_language') || 'en'
|
||||
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
|
||||
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
@@ -9,25 +38,44 @@ const apiClient: AxiosInstance = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add 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(
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor - handle 401
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
|
||||
const publicPrefixes = ['/shared/', '/public/']
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +86,16 @@ apiClient.interceptors.response.use(
|
||||
) {
|
||||
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)
|
||||
}
|
||||
)
|
||||
@@ -63,6 +121,8 @@ export const authApi = {
|
||||
validateKeys: () => apiClient.get('/auth/validate-keys').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),
|
||||
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
mcpTokens: {
|
||||
@@ -72,6 +132,43 @@ export const authApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export const oauthApi = {
|
||||
/** Validate OAuth authorize params — called by consent page on load */
|
||||
validate: (params: {
|
||||
response_type: string
|
||||
client_id: string
|
||||
redirect_uri: string
|
||||
scope: string
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||
|
||||
/** Submit user consent (approve or deny) */
|
||||
authorize: (body: {
|
||||
client_id: string
|
||||
redirect_uri: string
|
||||
scope: string
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
approved: boolean
|
||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||
|
||||
clients: {
|
||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||
},
|
||||
|
||||
sessions: {
|
||||
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
|
||||
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export const tripsApi = {
|
||||
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),
|
||||
@@ -85,6 +182,7 @@ export const tripsApi = {
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
@@ -101,12 +199,27 @@ export const placesApi = {
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
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),
|
||||
importGpx: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
|
||||
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
|
||||
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -177,6 +290,14 @@ export const adminApi = {
|
||||
checkVersion: () => apiClient.get('/admin/version-check').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),
|
||||
@@ -195,6 +316,8 @@ export const adminApi = {
|
||||
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),
|
||||
@@ -202,20 +325,80 @@ export const adminApi = {
|
||||
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 = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const journeyApi = {
|
||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
|
||||
|
||||
suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data),
|
||||
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
|
||||
|
||||
// Trips (sync sources)
|
||||
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
|
||||
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
|
||||
|
||||
// Entries
|
||||
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
|
||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||
|
||||
// Cover
|
||||
uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
|
||||
// Contributors
|
||||
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
|
||||
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
|
||||
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
|
||||
|
||||
// Preferences
|
||||
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
|
||||
|
||||
// Share
|
||||
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const budgetApi = {
|
||||
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),
|
||||
@@ -225,6 +408,8 @@ export const budgetApi = {
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
@@ -256,6 +441,11 @@ export const weatherApi = {
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
||||
@@ -334,6 +524,7 @@ export const notificationsApi = {
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,8 @@ let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | 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 {
|
||||
return mySocketId
|
||||
@@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||
@@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
}
|
||||
})
|
||||
if (refetchCallback) {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
const doRefetch = () => {
|
||||
activeTrips.forEach(tripId => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,31 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } 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 = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||
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 {
|
||||
@@ -38,7 +59,16 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
@@ -103,11 +133,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
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 integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
const photosAddon = tripAddons.find(isPhotosAddon)
|
||||
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
@@ -153,44 +183,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow
|
||||
addon={addon}
|
||||
onToggle={handleToggle}
|
||||
t={t}
|
||||
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
|
||||
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
|
||||
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
|
||||
hideToggle={photosAddon && addon.id === photosAddon.id}
|
||||
/>
|
||||
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||
@@ -208,6 +204,36 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -223,7 +249,41 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</span>
|
||||
</div>
|
||||
{globalAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2 } from 'lucide-react'
|
||||
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
|
||||
@@ -14,21 +26,49 @@ interface AdminMcpToken {
|
||||
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 [isLoading, setIsLoading] = useState(true)
|
||||
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(() => {
|
||||
setIsLoading(true)
|
||||
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(() => setIsLoading(false))
|
||||
.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)
|
||||
@@ -47,55 +87,156 @@ export default function AdminMcpTokensPanel() {
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
{/* OAuth Sessions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||
<span>{t('admin.oauthSessions.owner')}</span>
|
||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{sessions.map((session, i) => {
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div key={session.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
|
||||
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{session.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP Tokens */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{tokensLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
|
||||
@@ -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,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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-012
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildCategory } from '../../../tests/helpers/factories';
|
||||
import CategoryManager from './CategoryManager';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [] })
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
|
||||
});
|
||||
|
||||
describe('CategoryManager', () => {
|
||||
it('FE-COMP-CAT-001: renders without crashing', () => {
|
||||
render(<CategoryManager />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-002: shows Categories title', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Categories');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('No categories yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-004: shows New Category button', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-006: shows existing categories from API', async () => {
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({
|
||||
categories: [
|
||||
buildCategory({ name: 'Museum' }),
|
||||
buildCategory({ name: 'Restaurant' }),
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Museum');
|
||||
expect(screen.getByText('Restaurant')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-007: clicking Create submits POST API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/categories', async ({ request }) => {
|
||||
postCalled = true;
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
category: buildCategory({ name: String(body.name) }),
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
const nameInput = screen.getByPlaceholderText('Category name');
|
||||
await user.type(nameInput, 'Parks');
|
||||
await user.click(screen.getByText('Create'));
|
||||
await waitFor(() => expect(postCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] })
|
||||
)
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
// Edit button is icon-only (no title) — find all buttons and click the first action button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Buttons: [New Category, ...action buttons for the category]
|
||||
// The edit button is the first action button in the category row (Edit2 icon)
|
||||
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[0]);
|
||||
// Name input pre-filled with category name
|
||||
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-009: delete button triggers DELETE API', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })
|
||||
),
|
||||
http.delete('/api/categories/9', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Parks');
|
||||
// Delete button is icon-only (Trash2, no title) — find the second action button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-010: shows subtitle text', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Manage categories for places');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-011: category count is shown', async () => {
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({
|
||||
categories: [buildCategory({ name: 'Cat1' }), buildCategory({ name: 'Cat2' })],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Cat1');
|
||||
await screen.findByText('Cat2');
|
||||
// Both categories rendered
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-012: Cancel button in form hides the form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
route_calculation?: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const save = async (patch: Partial<Defaults>) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
|
||||
setDefaults(updated)
|
||||
toast.success(t('admin.defaultSettings.saved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const reset = async (key: keyof Defaults) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
||||
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2"
|
||||
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const mapPreviewPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Preview center',
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||
onClick={() => save({ dark_mode: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
{([
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.temperature_unit === opt.value}
|
||||
onClick={() => save({ temperature_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.time_format === opt.value}
|
||||
onClick={() => save({ time_format: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.route_calculation === opt.value}
|
||||
onClick={() => save({ route_calculation: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.blur_booking_codes === opt.value}
|
||||
onClick={() => save({ blur_booking_codes: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import DevNotificationsPanel from './DevNotificationsPanel';
|
||||
|
||||
const ADMIN_USER = buildUser({ id: 1, username: 'testadmin', role: 'admin' });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: ADMIN_USER, isAuthenticated: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('DevNotificationsPanel', () => {
|
||||
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
// Wait for async data to populate conditional sections
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
await screen.findByText('User-Scoped Events');
|
||||
expect(screen.getByText('Type Testing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const options = Array.from(tripSelect.querySelectorAll('option'));
|
||||
const labels = options.map(o => o.textContent);
|
||||
expect(labels).toContain('Paris Adventure');
|
||||
expect(labels).toContain('Tokyo Trip');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('User-Scoped Events');
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
// Second combobox is the user selector (first is trip selector)
|
||||
const userSelect = selects[1];
|
||||
const options = Array.from(userSelect.querySelectorAll('option'));
|
||||
const labels = options.map(o => o.textContent ?? '');
|
||||
expect(labels.some(l => l.includes('admin'))).toBe(true);
|
||||
expect(labels.some(l => l.includes('alice'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await waitFor(() => expect(capturedBody).toBeDefined());
|
||||
expect(capturedBody).toMatchObject({
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: ADMIN_USER.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ ok: true }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText('Sent: simple-me');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async () => {
|
||||
await new Promise(() => {}); // never resolves — simulates in-flight
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
// Fire the click but do not await — handler never resolves so sending stays true
|
||||
void user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(btn => expect(btn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText(/failed|error/i);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
|
||||
o => o.textContent === 'Tokyo Trip',
|
||||
)!;
|
||||
const tokyoId = Number(tokyoOption.value);
|
||||
|
||||
await user.selectOptions(tripSelect, 'Tokyo Trip');
|
||||
await user.click(screen.getByText('booking_change').closest('button')!);
|
||||
|
||||
await waitFor(() => expect(capturedBody).toBeDefined());
|
||||
expect(capturedBody!.targetId).toBe(tokyoId);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
// Wait for user data to confirm async effects have settled
|
||||
await screen.findByText('User-Scoped Events');
|
||||
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,18 @@ import apiClient from '../../api/client'
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
export default function GitHubPanel() {
|
||||
interface GithubRelease {
|
||||
id: number
|
||||
prerelease: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState([])
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@@ -124,7 +130,7 @@ export default function GitHubPanel() {
|
||||
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-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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' }}
|
||||
@@ -142,7 +148,7 @@ export default function GitHubPanel() {
|
||||
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-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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' }}
|
||||
@@ -157,10 +163,10 @@ export default function GitHubPanel() {
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nSdKaXgN"
|
||||
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-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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' }}
|
||||
@@ -181,7 +187,7 @@ export default function GitHubPanel() {
|
||||
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-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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' }}
|
||||
@@ -199,7 +205,7 @@ export default function GitHubPanel() {
|
||||
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-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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' }}
|
||||
@@ -217,7 +223,7 @@ export default function GitHubPanel() {
|
||||
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-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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' }}
|
||||
@@ -273,7 +279,7 @@ export default function GitHubPanel() {
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{releases.map((release, idx) => {
|
||||
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import PackingTemplateManager from './PackingTemplateManager';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
|
||||
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' }
|
||||
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' }
|
||||
|
||||
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 }
|
||||
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 }
|
||||
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 }
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', async () => {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
return HttpResponse.json({ templates: [] });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-002: shows empty state when no templates', async () => {
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('No templates created yet');
|
||||
expect(screen.queryAllByRole('button', { name: /chevron/i })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-003: template list renders names and counts', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1, tmpl2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
// tmpl1 has 2 categories and 5 items
|
||||
expect(screen.getByText(/2 categories · 5 items/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-004: clicking "+" shows create input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('No templates created yet');
|
||||
const createBtn = screen.getByRole('button', { name: /new template/i });
|
||||
await user.click(createBtn);
|
||||
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-005: creates template on Enter and shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/admin/packing-templates', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ template: { id: 99, name: 'New Template' } });
|
||||
})
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('No templates created yet');
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||
await user.type(input, 'New Template{Enter}');
|
||||
await waitFor(() => expect(postCalled).toBe(true));
|
||||
// "New Template" may appear both as the button label and the new list item
|
||||
await waitFor(() => expect(screen.getAllByText('New Template').length).toBeGreaterThanOrEqual(1));
|
||||
await screen.findByText('Template created');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-006: Escape dismisses create input without API call', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/admin/packing-templates', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ template: { id: 99, name: 'Should Not Appear' } });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('No templates created yet');
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||
await user.type(input, 'Test{Escape}');
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-007: expanding a template loads and displays its categories and items', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-008: collapsing an expanded template hides its content', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
// Collapse by clicking again
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-009: deleting a template removes it from the list and shows toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1, tmpl2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
|
||||
// Find all Trash2 (delete) buttons — there are 2 (one per template)
|
||||
const deleteButtons = screen.getAllByRole('button').filter(b =>
|
||||
b.className.includes('hover:bg-red-50') || b.querySelector('svg')
|
||||
);
|
||||
// Click the delete button for "Beach Trip" (first template row's trash button)
|
||||
// The buttons layout in each row: [chevron, edit, delete]
|
||||
// We find rows first
|
||||
const beachTripRow = screen.getByText('Beach Trip').closest('div');
|
||||
const trashBtn = beachTripRow!.parentElement!.querySelector('button.hover\\:bg-red-50') as HTMLElement | null;
|
||||
if (trashBtn) {
|
||||
await user.click(trashBtn);
|
||||
} else {
|
||||
// Fallback: find all red-hover buttons and click first
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
|
||||
await user.click(redBtns[0]);
|
||||
}
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
await screen.findByText('Template deleted');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-010: renaming a template inline updates the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1', async () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
// Find the Edit2 button on the template row
|
||||
const beachTripText = screen.getByText('Beach Trip');
|
||||
const row = beachTripText.closest('div')!.parentElement!;
|
||||
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
// Fallback: find all slate-100-hover buttons
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Summer Packing{Enter}');
|
||||
await waitFor(() => expect(putCalled).toBe(true));
|
||||
await screen.findByText('Summer Packing');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-011: adding a category to an expanded template', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories', async () =>
|
||||
HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
// Wait for expanded state (Add category button should appear)
|
||||
await screen.findByText('Add category');
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, 'Electronics{Enter}');
|
||||
await screen.findByText('Electronics');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', async () =>
|
||||
HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
// Click the "+" button on the Clothing category row
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
|
||||
await user.click(addItemBtn);
|
||||
|
||||
const itemInput = screen.getByPlaceholderText('Item name');
|
||||
await user.type(itemInput, 'Sandals');
|
||||
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
|
||||
await user.type(itemInput, '{Enter}');
|
||||
await screen.findByText('Sandals');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', async () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
// Find the Edit2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('hover:text-slate-700')
|
||||
);
|
||||
// Second button (after Plus) is Edit2
|
||||
await user.click(editBtns[1]);
|
||||
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Shoes{Enter}');
|
||||
await screen.findByText('Shoes');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1/categories/10', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
|
||||
// Find the Trash2 button in the Clothing category header
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const trashBtn = clothingHeader.querySelector('button.hover\\:text-red-500') as HTMLElement;
|
||||
await user.click(trashBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-015: renaming an item inline updates its name', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1/items/100', async () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
|
||||
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
|
||||
b => b.className.includes('opacity-0')
|
||||
) as HTMLElement | undefined;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
// Directly click the first button in the item row
|
||||
const btns = itemRow.querySelectorAll('button');
|
||||
await user.click(btns[0] as HTMLElement);
|
||||
}
|
||||
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Tank Top{Enter}');
|
||||
await screen.findByText('Tank Top');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
|
||||
),
|
||||
http.delete('/api/admin/packing-templates/1/items/100', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('T-shirt');
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
|
||||
// Find the Trash2 button in the T-shirt row
|
||||
const itemRow = screen.getByText('T-shirt').closest('div')!;
|
||||
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
|
||||
b => b.className.includes('opacity-0')
|
||||
);
|
||||
// Second opacity-0 button is the delete (trash) button
|
||||
const trashBtn = trashBtns[1] || trashBtns[0];
|
||||
await user.click(trashBtn as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Shorts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-017: Escape cancels add category without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Ignored', sort_order: 1 } });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Add category');
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, 'Test{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument()
|
||||
);
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-018: Escape cancels add item without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.get('/api/admin/packing-templates/1', () =>
|
||||
HttpResponse.json({ categories: [cat1], items: [] })
|
||||
),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', async () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Ignored', sort_order: 2 } });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
const clothingHeader = screen.getByText('Clothing').closest('div')!;
|
||||
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
|
||||
await user.click(addItemBtn);
|
||||
|
||||
const itemInput = screen.getByPlaceholderText('Item name');
|
||||
await user.type(itemInput, 'Test{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
|
||||
);
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-019: Escape cancels template rename without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () =>
|
||||
HttpResponse.json({ templates: [tmpl1] })
|
||||
),
|
||||
http.put('/api/admin/packing-templates/1', async () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
const beachTripText = screen.getByText('Beach Trip');
|
||||
const row = beachTripText.closest('div')!.parentElement!;
|
||||
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
|
||||
if (editBtn) {
|
||||
await user.click(editBtn);
|
||||
} else {
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
|
||||
await user.click(editBtns[0]);
|
||||
}
|
||||
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.type(input, '{Escape}');
|
||||
await waitFor(() => expect(screen.queryByDisplayValue('Beach Trip')).not.toBeInTheDocument());
|
||||
expect(putCalled).toBe(false);
|
||||
// Original name should be restored
|
||||
expect(screen.getByText('Beach Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-020: X button on create template input dismisses it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('No templates created yet');
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
|
||||
|
||||
// Find the X (cancel) button in the create row — it's the last button in the create row
|
||||
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
|
||||
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
// FE-ADMIN-PERM-001 to FE-ADMIN-PERM-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import PermissionsPanel from './PermissionsPanel';
|
||||
|
||||
// ── Fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED = ['admin', 'trip_owner', 'trip_member', 'everybody'] as const;
|
||||
|
||||
function buildPermission(key: string, level = 'trip_member', defaultLevel = 'trip_member') {
|
||||
return { key, level, defaultLevel, allowedLevels: [...ALLOWED] };
|
||||
}
|
||||
|
||||
const SAMPLE_PERMISSIONS = [
|
||||
buildPermission('trip_create'),
|
||||
buildPermission('trip_edit'),
|
||||
buildPermission('trip_delete'),
|
||||
buildPermission('trip_archive'),
|
||||
buildPermission('trip_cover_upload'),
|
||||
buildPermission('member_manage'),
|
||||
buildPermission('file_upload'),
|
||||
buildPermission('file_edit'),
|
||||
buildPermission('file_delete'),
|
||||
buildPermission('place_edit'),
|
||||
buildPermission('day_edit'),
|
||||
buildPermission('reservation_edit'),
|
||||
buildPermission('budget_edit'),
|
||||
buildPermission('packing_edit'),
|
||||
buildPermission('collab_edit'),
|
||||
buildPermission('share_manage'),
|
||||
];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<PermissionsPanel />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Override the default handler (returns object) with correct array shape
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PermissionsPanel', () => {
|
||||
it('FE-ADMIN-PERM-001: loading spinner renders before data arrives', () => {
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json({ permissions: [] });
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
// The form content (category headings) should not be present
|
||||
expect(screen.queryByText('Trip Management')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-002: permission categories and actions render after load', async () => {
|
||||
renderPanel();
|
||||
// Wait until loading is done — a category heading appears
|
||||
await screen.findByText('Trip Management');
|
||||
expect(screen.getByText('Member Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||
expect(screen.getByText('Content & Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByText('Budget, Packing & Collaboration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add / remove members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-003: "customized" badge visible when value differs from default', async () => {
|
||||
const perms = [
|
||||
buildPermission('trip_create', 'admin', 'trip_member'), // level ≠ default → badge
|
||||
buildPermission('trip_edit', 'trip_member', 'trip_member'), // level === default → no badge
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: perms }),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
// Badge should appear once (for trip_create)
|
||||
expect(screen.getByText('customized')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('customized')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-004: Save button is disabled until a value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Open the first CustomSelect trigger (shows current level "Trip members")
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
|
||||
// Pick an option different from the current one (current is trip_member → pick admin)
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-005: changing a value marks form dirty and enables Save', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Open first CustomSelect dropdown and select a different option
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-006: Reset button restores values to defaultLevel and enables Save', async () => {
|
||||
const perms = [
|
||||
buildPermission('trip_create', 'admin', 'trip_member'), // customized
|
||||
...SAMPLE_PERMISSIONS.filter(p => p.key !== 'trip_create'),
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: perms }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
// Customized badge should be visible
|
||||
expect(screen.getByText('customized')).toBeInTheDocument();
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
const resetButton = screen.getByRole('button', { name: /Reset to defaults/i });
|
||||
|
||||
await user.click(resetButton);
|
||||
|
||||
// Badge should disappear (value back to defaultLevel)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('customized')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Save should be enabled (handleReset sets dirty=true)
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-007: successful save calls PUT and shows success toast', async () => {
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
// Dirty the form
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||
await user.click(saveButton);
|
||||
|
||||
await screen.findByText('Permission settings saved');
|
||||
// After successful save, dirty is cleared → Save disabled again
|
||||
await waitFor(() => expect(saveButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-008: failed save shows error toast and keeps Save enabled', async () => {
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
// Dirty the form
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||
await user.click(saveButton);
|
||||
|
||||
await screen.findByText('Error');
|
||||
// Dirty unchanged → Save stays enabled
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-009: Save button is disabled while save is in-flight', async () => {
|
||||
let resolvePut!: () => void;
|
||||
server.use(
|
||||
http.put('/api/admin/permissions', () =>
|
||||
new Promise<Response>(resolve => {
|
||||
resolvePut = () =>
|
||||
resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
await screen.findByText('Trip Management');
|
||||
|
||||
// Dirty the form
|
||||
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
|
||||
await user.click(triggers[0]);
|
||||
const adminOption = await screen.findByText('Admin only');
|
||||
await user.click(adminOption);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||
await user.click(saveButton);
|
||||
|
||||
// In-flight: button should be disabled and show Loader2 spinner
|
||||
await waitFor(() => expect(saveButton).toBeDisabled());
|
||||
const loader = saveButton.querySelector('.animate-spin');
|
||||
expect(loader).toBeInTheDocument();
|
||||
|
||||
// Resolve the request
|
||||
resolvePut();
|
||||
await screen.findByText('Permission settings saved');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PERM-010: load failure shows error toast', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/permissions', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Error');
|
||||
});
|
||||
});
|
||||
@@ -107,10 +107,12 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
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" />
|
||||
{t('perm.resetDefaults')}
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BudgetPanel from './BudgetPanel';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Settlement and per-person APIs needed by BudgetPanel
|
||||
server.use(
|
||||
http.get('/api/trips/:id/budget/settlement', () =>
|
||||
HttpResponse.json({ balances: [], flows: [] })
|
||||
),
|
||||
http.get('/api/trips/:id/budget/per-person', () =>
|
||||
HttpResponse.json({ summary: [] })
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
});
|
||||
|
||||
describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('No budget created yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText(/Create categories and entries/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('Enter category name...');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Hotel Paris');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-005: renders category section header', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Transport');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Name');
|
||||
await screen.findByText('Total');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Budget');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('CSV');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('New Entry');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' });
|
||||
return HttpResponse.json({ item });
|
||||
})
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
const nameInput = await screen.findByPlaceholderText('New Entry');
|
||||
await user.type(nameInput, 'Restaurant Dinner');
|
||||
const addBtn = screen.getByTitle('Add Reservation');
|
||||
await user.click(addBtn);
|
||||
await screen.findByText('Restaurant Dinner');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Test Item');
|
||||
// Delete button has title="Delete"
|
||||
expect(screen.getByTitle('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-012: delete item removes it from the UI', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = buildBudgetItem({ id: 42, trip_id: 1, category: 'Food', name: 'Item To Delete' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.delete('/api/trips/1/budget/42', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Item To Delete');
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Item To Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => {
|
||||
const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' });
|
||||
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Hotel A');
|
||||
await screen.findByText('Hotel B');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
|
||||
const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' });
|
||||
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Transport');
|
||||
await screen.findByText('Hotels');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
// Component renders even in empty state
|
||||
await screen.findByText('No budget created yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Misc');
|
||||
// Row exists - EUR formatting would appear in values
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('ToDelete');
|
||||
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => {
|
||||
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('New Entry');
|
||||
// The add button is present
|
||||
expect(screen.getByTitle('Add Reservation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-019: add item with Enter key submits the form', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' });
|
||||
return HttpResponse.json({ item });
|
||||
})
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
const nameInput = await screen.findByPlaceholderText('New Entry');
|
||||
await user.type(nameInput, 'Pizza{Enter}');
|
||||
await screen.findByText('Pizza');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} tripMembers={[]} />);
|
||||
await screen.findByText('No budget created yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Old Name');
|
||||
await user.click(screen.getByText('Old Name'));
|
||||
expect(screen.getByDisplayValue('Old Name')).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByDisplayValue('Old Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-022: inline edit name cell — saving new name calls PUT API', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ id: 10, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
|
||||
let putCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.put('/api/trips/1/budget/10', async ({ request }) => {
|
||||
const b = await request.json() as Record<string, unknown>;
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ item: { ...item, name: b.name } });
|
||||
})
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Old Name');
|
||||
await user.click(screen.getByText('Old Name'));
|
||||
const input = screen.getByDisplayValue('Old Name');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Name');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(putCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => {
|
||||
const item = { ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }), total_price: 45.5 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Dinner');
|
||||
// The formatted number appears in the InlineEditCell for total price (and grand total card)
|
||||
expect(screen.getAllByText('45.50').length).toBeGreaterThan(0);
|
||||
// The currency symbol appears (in category subtotal or grand total card)
|
||||
expect(screen.getAllByText(/€/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }), total_price: 200 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findAllByText('Flights');
|
||||
await screen.findByText('Flight to Paris');
|
||||
await user.click(screen.getByTitle('Delete Category'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByText('Flights').length).toBe(0);
|
||||
expect(screen.queryByText('Flight to Paris')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-025: CSV export button triggers download via URL.createObjectURL', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:test');
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL);
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('CSV');
|
||||
await user.click(screen.getByText('CSV'));
|
||||
expect(createObjectURL).toHaveBeenCalled();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => {
|
||||
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 };
|
||||
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Lunch');
|
||||
// The category header shows subtotal formatted as "50.00 €" (also appears in pie legend)
|
||||
expect(screen.getAllByText('50.00 €').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('Enter category name...');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.post('/api/trips/1/budget', () =>
|
||||
HttpResponse.json({ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 } })
|
||||
)
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
const input = await screen.findByPlaceholderText('Enter category name...');
|
||||
await user.type(input, 'Souvenirs{Enter}');
|
||||
await screen.findByText('Souvenirs');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 100 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', balance: -10, avatar_url: null },
|
||||
{ user_id: 2, username: 'bob', balance: 10, avatar_url: null },
|
||||
],
|
||||
flows: [
|
||||
{ from: { username: 'alice', avatar_url: null }, to: { username: 'bob', avatar_url: null }, amount: 10 },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Dinner');
|
||||
// Click the settlement toggle button (role button with name containing "settlement")
|
||||
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
|
||||
await user.click(settlementBtn);
|
||||
// alice and bob should appear in balances section
|
||||
await screen.findByText('alice');
|
||||
await screen.findByText('bob');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => {
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
|
||||
total_price: 75,
|
||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/summary/per-person', () =>
|
||||
HttpResponse.json({ summary: [{ user_id: 1, username: 'testuser', avatar_url: null, total_assigned: 75 }] })
|
||||
)
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'testuser', avatar_url: null },
|
||||
{ id: 2, username: 'other', avatar_url: null },
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Shared Dinner');
|
||||
await screen.findByText('testuser');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
|
||||
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 };
|
||||
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Flight');
|
||||
await screen.findByText('Hotel');
|
||||
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
|
||||
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
// Use a user with id != 1 so they're not the owner
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Read Only Item');
|
||||
// In read-only mode the Delete button should not be visible
|
||||
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Train');
|
||||
// expense_date is rendered as plain text in read-only mode
|
||||
await screen.findByText('2025-06-15');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
|
||||
],
|
||||
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
|
||||
})
|
||||
),
|
||||
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Lunch');
|
||||
// Trigger settlement display
|
||||
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
|
||||
await user.click(settlementBtn);
|
||||
await screen.findByText('alice');
|
||||
// Avatar image should be rendered for alice
|
||||
const avatarImg = screen.getAllByRole('img');
|
||||
expect(avatarImg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Snack');
|
||||
// When expense_date is null, the fallback '—' is shown
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,69 @@ import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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))',
|
||||
}
|
||||
}
|
||||
|
||||
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('')}`
|
||||
}
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -75,9 +137,29 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
const handlePaste = (e) => {
|
||||
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}
|
||||
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} />
|
||||
@@ -131,6 +213,7 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
</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' }}>
|
||||
@@ -340,9 +423,47 @@ interface PerPersonInlineProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
|
||||
const [data, setData] = useState(null)
|
||||
const fmt = (v) => fmtNum(v, locale, currency)
|
||||
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)' },
|
||||
]
|
||||
|
||||
export function splitColorFor(userId: number, order: number) {
|
||||
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
function colorForUserId(userId: number) {
|
||||
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
|
||||
const [data, setData] = useState<any[] | null>(null)
|
||||
const fmt = (v: number) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
@@ -350,25 +471,38 @@ function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInl
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||
|
||||
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>
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -395,11 +529,14 @@ function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
|
||||
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
|
||||
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%)',
|
||||
@@ -422,9 +559,11 @@ interface BudgetPanelProps {
|
||||
}
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const { t, locale } = useTranslation()
|
||||
const isDark = useIsDark()
|
||||
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||
@@ -435,6 +574,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
const fmt = (v, cur) => 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
|
||||
@@ -447,21 +594,34 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
|
||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||
|
||||
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 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 = Object.keys(grouped)
|
||||
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(() =>
|
||||
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],
|
||||
value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
|
||||
color: categoryColor(cat),
|
||||
})).filter(s => s.value > 0)
|
||||
, [grouped, categoryNames])
|
||||
|
||||
@@ -469,12 +629,12 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
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] || []
|
||||
const items = grouped.get(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] || []
|
||||
const items = grouped.get(oldName) || []
|
||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
@@ -494,7 +654,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
const rows = [header.join(sep)]
|
||||
|
||||
for (const cat of categoryNames) {
|
||||
for (const item of (grouped[cat] || [])) {
|
||||
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)
|
||||
@@ -547,30 +707,115 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
}
|
||||
|
||||
// ── Main Layout ──────────────────────────────────────────────────────────
|
||||
const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
return (
|
||||
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Calculator size={20} color="var(--text-primary)" />
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||
<div>
|
||||
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
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="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<div 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 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} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Download size={13} /> CSV
|
||||
</button>
|
||||
</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 }}>
|
||||
{categoryNames.map((cat, ci) => {
|
||||
const items = grouped[cat]
|
||||
const items = grouped.get(cat) || []
|
||||
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
const color = PIE_COLORS[ci % PIE_COLORS.length]
|
||||
const color = categoryColor(cat)
|
||||
|
||||
return (
|
||||
<div key={cat} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||
<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
|
||||
@@ -606,7 +851,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
|
||||
<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>
|
||||
@@ -629,24 +875,56 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
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' }}
|
||||
<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}>
|
||||
<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} />
|
||||
{/* 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)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
compact={false}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
<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} />
|
||||
@@ -701,61 +979,57 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
disabled={!canEdit}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<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 className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
|
||||
<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)',
|
||||
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: 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 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>
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</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>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||
|
||||
{(() => {
|
||||
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>
|
||||
<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} />
|
||||
<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 rgba(255,255,255,0.1)', paddingTop: 12 }}>
|
||||
<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: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
@@ -780,53 +1054,60 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{settlement.flows.map((flow, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
|
||||
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>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</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>
|
||||
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
||||
<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: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||
<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>
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
|
||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
|
||||
}}>
|
||||
{b.avatar_url
|
||||
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: b.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
||||
color: b.balance > 0 ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
|
||||
</span>
|
||||
</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>
|
||||
@@ -835,32 +1116,115 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
)}
|
||||
</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>
|
||||
{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>
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{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: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
|
||||
<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>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -370,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { deleteTimersRef.current.forEach(clearTimeout) }
|
||||
}, [])
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const messagesRef = useRef(messages)
|
||||
@@ -483,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
setTimeout(async () => {
|
||||
const t = 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)
|
||||
deleteTimersRef.current.push(t)
|
||||
}, [tripId])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
@@ -762,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}>
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
@@ -110,10 +112,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
const isTxt = file.mime_type?.startsWith('text/')
|
||||
|
||||
const openInNewTab = async () => {
|
||||
const u = await getAuthUrl(rawUrl, 'download')
|
||||
window.open(u, '_blank', 'noreferrer')
|
||||
}
|
||||
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||
@@ -313,7 +312,6 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
padding: 16,
|
||||
fontFamily: FONT,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<form
|
||||
style={{
|
||||
@@ -846,7 +844,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1353,7 +1351,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser } from '../../../tests/helpers/factories'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
|
||||
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
|
||||
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
|
||||
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}))
|
||||
|
||||
import CollabPanel from './CollabPanel'
|
||||
|
||||
let originalInnerWidth: number
|
||||
|
||||
function setViewport(width: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}
|
||||
|
||||
describe('CollabPanel', () => {
|
||||
beforeEach(() => {
|
||||
originalInnerWidth = window.innerWidth
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser() })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-001
|
||||
it('desktop layout renders all four panels', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-002
|
||||
it('mobile layout renders tab bar, not all panels at once', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
// Tab buttons exist
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
|
||||
// Only chat visible by default
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-003
|
||||
it('mobile: clicking Notes tab switches to CollabNotes', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /notes/i }))
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-004
|
||||
it('mobile: clicking Polls tab switches to CollabPolls', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /polls/i }))
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-005
|
||||
it('mobile: clicking What\'s Next tab shows WhatsNextWidget', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /what.?s next/i }))
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-006
|
||||
it('mobile: active tab button has accent background style', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
const chatButton = screen.getByRole('button', { name: /chat/i })
|
||||
expect(chatButton.style.background).toBe('var(--accent)')
|
||||
const notesButton = screen.getByRole('button', { name: /notes/i })
|
||||
expect(notesButton.style.background).toBe('transparent')
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-007
|
||||
it('mobile: default active tab is Chat', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-008
|
||||
it('tripMembers prop is forwarded to WhatsNextWidget', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} tripMembers={[{ id: 5, username: 'alice', avatar_url: null }]} />)
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-009
|
||||
it('tripId prop is forwarded to child components', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
// All children render without errors, confirming props were forwarded
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-010
|
||||
it('resize from desktop to mobile hides side-by-side layout', () => {
|
||||
setViewport(1280)
|
||||
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||
// All four panels visible on desktop
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
|
||||
// Switch to mobile
|
||||
setViewport(375)
|
||||
rerender(<CollabPanel tripId={1} />)
|
||||
|
||||
// Tab bar appears, only chat visible
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
@@ -29,54 +29,142 @@ interface TripMember {
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface CollabFeatures {
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
|
||||
interface CollabPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
collabFeatures?: CollabFeatures
|
||||
}
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [mobileTab, setMobileTab] = useState('chat')
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
|
||||
]
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat')
|
||||
|
||||
// If active tab gets disabled, switch to first available
|
||||
useEffect(() => {
|
||||
if (tabs.length > 0 && !tabs.some(t => t.id === mobileTab)) {
|
||||
setMobileTab(tabs[0].id)
|
||||
}
|
||||
}, [tabs, mobileTab])
|
||||
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
// Chat always 380px fixed when on. Right panels share remaining space.
|
||||
// If chat off, all panels share full width equally.
|
||||
if (chatOn && rightPanels.length === 0) {
|
||||
// Only chat
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (chatOn) {
|
||||
// Chat left (380px) + right panels
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Chat — left, fixed width */}
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Right column: Notes top, Polls + What's Next bottom */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Notes — top */}
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
{panels.map(p => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
|
||||
{/* Polls + What's Next — bottom row */}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: tab bar + single panel
|
||||
// Mobile: tab bar + single panel (only enabled tabs)
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
||||
<div style={{
|
||||
@@ -84,7 +172,6 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
|
||||
background: 'var(--bg-card)', flexShrink: 0,
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
@@ -102,10 +189,10 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// FE-COMP-POLLS-001 to FE-COMP-POLLS-015
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import CollabPolls from './CollabPolls';
|
||||
import { addListener } from '../../api/websocket';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||
|
||||
const buildPoll = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
question: 'Best destination?',
|
||||
options: [
|
||||
{ id: 1, text: 'Paris', label: 'Paris', voters: [] },
|
||||
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
|
||||
],
|
||||
multi_choice: false,
|
||||
is_closed: false,
|
||||
deadline: null,
|
||||
created_by: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = { tripId: 1, currentUser };
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [] }),
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
|
||||
});
|
||||
|
||||
describe('CollabPolls', () => {
|
||||
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-002: shows loading spinner initially', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', async () => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return HttpResponse.json({ polls: [] });
|
||||
}),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
// The spinner is a div with animation style
|
||||
expect(
|
||||
document.querySelector('[style*="animation"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-003: renders poll question from API', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll()] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-004: renders poll options', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll()] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Paris');
|
||||
expect(screen.getByText('Rome')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-005: New Poll button is visible when user can edit', async () => {
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
// Wait for loading to finish
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-006: clicking New Poll button opens the create modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
// Modal has a question placeholder input
|
||||
await screen.findByPlaceholderText(/what should we do/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-007: create modal requires question and at least 2 options to enable submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
|
||||
// Find submit button - it's the form submit with the create label
|
||||
const submitBtn = screen.getByRole('button', { name: /create|collab\.polls\.create/i });
|
||||
expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Fill in question
|
||||
const questionInput = screen.getByPlaceholderText(/what should we do/i);
|
||||
await user.type(questionInput, 'Where to go?');
|
||||
|
||||
// Still disabled — no options filled
|
||||
expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Fill in 2 options
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
await user.type(optionInputs[0], 'Beach');
|
||||
await user.type(optionInputs[1], 'Mountain');
|
||||
|
||||
expect(submitBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-008: creating a poll calls POST API and adds it to the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Where to eat?');
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
await user.type(optionInputs[0], 'Italian');
|
||||
await user.type(optionInputs[1], 'Japanese');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||
await screen.findByText('Where to eat?');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
|
||||
let voteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll()] }),
|
||||
),
|
||||
http.post('/api/trips/1/collab/polls/1/vote', () => {
|
||||
voteCalled = true;
|
||||
return HttpResponse.json({
|
||||
poll: buildPoll({
|
||||
options: [
|
||||
{ id: 1, text: 'Paris', label: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
|
||||
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Paris');
|
||||
await user.click(screen.getByText('Paris'));
|
||||
await waitFor(() => expect(voteCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-010: closed poll shows "Closed" badge', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/closed/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Paris');
|
||||
const parisBtn = screen.getByText('Paris').closest('button');
|
||||
expect(parisBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-012: delete button calls DELETE API and removes poll', async () => {
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ id: 5 })] }),
|
||||
),
|
||||
http.delete('/api/trips/1/collab/polls/5', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
|
||||
// Delete button has a title with "delete"
|
||||
const deleteBtn = screen.getByTitle(/delete/i);
|
||||
await user.click(deleteBtn);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-013: WebSocket collab:poll:created event adds poll', async () => {
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
|
||||
// Get the WS listener that was registered
|
||||
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
listener({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
|
||||
|
||||
await screen.findByText('Live poll?');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ id: 3 })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
|
||||
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
listener({ type: 'collab:poll:deleted', pollId: 3 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-015: adding a third option in create modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
|
||||
// Initially 2 option inputs
|
||||
let optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
expect(optionInputs).toHaveLength(2);
|
||||
|
||||
// Click "Add option"
|
||||
await user.click(screen.getByText(/add option/i));
|
||||
|
||||
optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
expect(optionInputs).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
|
||||
|
||||
// Dynamic date helpers
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
function getFutureDate(daysAhead: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + daysAhead)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function getPastDate(daysBack: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - daysBack)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const tomorrow = getFutureDate(1)
|
||||
const yesterday = getPastDate(1)
|
||||
|
||||
function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}, participants: unknown[] = []) {
|
||||
return {
|
||||
id,
|
||||
day_id: 1,
|
||||
place_id: id,
|
||||
order_index: 0,
|
||||
notes: null,
|
||||
place: {
|
||||
id,
|
||||
trip_id: 1,
|
||||
name: `Place ${id}`,
|
||||
description: null,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...placeOverrides,
|
||||
},
|
||||
participants,
|
||||
}
|
||||
}
|
||||
|
||||
describe('WhatsNextWidget', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-001: renders empty state when no days exist', () => {
|
||||
seedStore(useTripStore, { days: [], assignments: {} })
|
||||
render(<WhatsNextWidget />)
|
||||
// Translation resolves to "No upcoming activities"
|
||||
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Place 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-001b: empty state element is rendered', () => {
|
||||
seedStore(useTripStore, { days: [], assignments: {} })
|
||||
render(<WhatsNextWidget />)
|
||||
// collab.whatsNext.empty key is rendered as text in test env
|
||||
const allText = document.body.textContent || ''
|
||||
// No assignment time/name visible — just the header and empty hint
|
||||
expect(allText).not.toContain('14:30')
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(10, { place_time: '08:00' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.queryByText('08:00')).toBeNull()
|
||||
expect(screen.queryByText('Place 10')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(21, { name: 'Museum' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
// The label text comes from t('collab.whatsNext.tomorrow') which falls back to 'Tomorrow'
|
||||
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText(/today/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('14:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('2:30 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('TBD')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-010: caps list at 8 items', () => {
|
||||
const days = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
trip_id: 1,
|
||||
date: getFutureDate(i + 1),
|
||||
title: null,
|
||||
order: i,
|
||||
assignments: [],
|
||||
notes_items: [],
|
||||
notes: null,
|
||||
}))
|
||||
|
||||
const assignments: Record<string, unknown[]> = {}
|
||||
let placeId = 100
|
||||
for (const day of days) {
|
||||
assignments[String(day.id)] = [
|
||||
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '10:00' }),
|
||||
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '11:00' }),
|
||||
]
|
||||
}
|
||||
|
||||
seedStore(useTripStore, { days, assignments })
|
||||
render(<WhatsNextWidget />)
|
||||
|
||||
// 10 items seeded, only 8 should appear — count "TBD" or time occurrences
|
||||
const timeElements = screen.getAllByText('10:00')
|
||||
// At most 4 days * 1 morning slot = up to 4 "10:00" entries, but capped at 8 total items
|
||||
// We verify total rendered items is at most 8 by counting both time slots
|
||||
const allTimes = screen.getAllByText(/10:00|11:00/)
|
||||
expect(allTimes.length).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(41, { name: 'Park' }, [])],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />)
|
||||
expect(screen.getByText('bob')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('19:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('21:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [
|
||||
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
|
||||
makeAssignment(61, { name: 'Lunch', place_time: '12:00' }),
|
||||
],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
const tomorrowHeaders = screen.getAllByText(/tomorrow/i)
|
||||
// Only one day header for tomorrow
|
||||
expect(tomorrowHeaders).toHaveLength(1)
|
||||
expect(screen.getByText('Breakfast')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lunch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
|
||||
// If it's not midnight, a past-time event today should not appear
|
||||
const now = new Date()
|
||||
if (now.getHours() > 0) {
|
||||
const pastTime = '00:01' // Very early — will be past for most of the day
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
// If current time > 00:01, the item should not appear
|
||||
if (now.getHours() > 0 || now.getMinutes() > 1) {
|
||||
expect(screen.queryByText('Early Bird')).toBeNull()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -16,12 +16,13 @@ function formatTime(timeStr, is12h) {
|
||||
}
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const d = new Date(date + 'T00:00:00')
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
||||
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
|
||||
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import TimezoneWidget from './TimezoneWidget'
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||
})
|
||||
|
||||
describe('TimezoneWidget', () => {
|
||||
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
|
||||
render(<TimezoneWidget />)
|
||||
expect(document.body).toBeInTheDocument()
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
|
||||
render(<TimezoneWidget />)
|
||||
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
|
||||
expect(timeElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
|
||||
localStorage.clear()
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('New York')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
// Find and click Berlin in the popular zones list
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||
await user.click(berlinButton)
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
// Panel should be closed
|
||||
expect(screen.queryByText('Custom Timezone')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
// Type label and timezone
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(labelInput, 'My City')
|
||||
await user.type(tzInput, 'Europe/Paris')
|
||||
// Click Add
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText('My City')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(tzInput, 'Invalid/Timezone')
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Default zones include New York (America/New_York)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(tzInput, 'America/New_York')
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
|
||||
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
|
||||
// Remove buttons for New York and Tokyo come after the Plus button
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
|
||||
await user.click(allButtons[1])
|
||||
expect(screen.queryByText('New York')).toBeNull()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||
await user.click(berlinButton)
|
||||
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
|
||||
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(labelInput, 'Singapore')
|
||||
await user.type(tzInput, 'Asia/Singapore')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(await screen.findByText('Singapore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,584 @@
|
||||
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import FileManager from './FileManager';
|
||||
|
||||
// Mock getAuthUrl
|
||||
vi.mock('../../api/authUrl', () => ({
|
||||
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
|
||||
}));
|
||||
|
||||
// Mock filesApi
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const original = (await importOriginal()) as any;
|
||||
return {
|
||||
...original,
|
||||
filesApi: {
|
||||
list: vi.fn().mockResolvedValue({ files: [] }),
|
||||
toggleStar: vi.fn().mockResolvedValue({}),
|
||||
restore: vi.fn().mockResolvedValue({}),
|
||||
permanentDelete: vi.fn().mockResolvedValue({}),
|
||||
emptyTrash: vi.fn().mockResolvedValue({}),
|
||||
upload: vi.fn().mockResolvedValue({ file: { id: 99 } }),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
addLink: vi.fn().mockResolvedValue({}),
|
||||
removeLink: vi.fn().mockResolvedValue({}),
|
||||
getLinks: vi.fn().mockResolvedValue({ links: [] }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { filesApi } from '../../api/client';
|
||||
|
||||
const buildFile = (overrides = {}) => ({
|
||||
id: 1,
|
||||
original_name: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size: 51200,
|
||||
created_at: '2025-01-10T08:00:00Z',
|
||||
url: '/uploads/trips/1/report.pdf',
|
||||
starred: false,
|
||||
deleted_at: null,
|
||||
place_id: null,
|
||||
reservation_id: null,
|
||||
day_id: null,
|
||||
uploaded_by: 1,
|
||||
uploader_name: 'Alice',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
files: [],
|
||||
onUpload: vi.fn().mockResolvedValue({}),
|
||||
onDelete: vi.fn().mockResolvedValue(undefined),
|
||||
onUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
places: [],
|
||||
days: [],
|
||||
assignments: {},
|
||||
reservations: [],
|
||||
tripId: 1,
|
||||
allowedFileTypes: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
// Seed auth as admin so useCanDo() returns true for all permissions
|
||||
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
|
||||
// Default trash endpoint
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/files', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('trash') === 'true') {
|
||||
return HttpResponse.json({ files: [] });
|
||||
}
|
||||
return HttpResponse.json({ files: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
// Stub window.confirm
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('FileManager', () => {
|
||||
it('FE-COMP-FILEMANAGER-001: renders empty state when no files', async () => {
|
||||
render(<FileManager {...defaultProps} files={[]} />);
|
||||
// The dropzone should be visible (Upload icon area)
|
||||
expect(screen.getByText(/drop/i)).toBeInTheDocument();
|
||||
// No file rows
|
||||
expect(screen.queryByText('report.pdf')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-002: renders file list when files are provided', async () => {
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||
expect(screen.getByText('report.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-003: file type filter tabs are present', async () => {
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||
// Filter tabs should be present — match the button elements specifically
|
||||
expect(screen.getByRole('button', { name: /^all$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^pdfs$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^images$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-004: images tab filters to image files only', async () => {
|
||||
const files = [
|
||||
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
|
||||
buildFile({ id: 2, mime_type: 'application/pdf', original_name: 'doc.pdf' }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
// Both should be visible initially
|
||||
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
|
||||
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
|
||||
|
||||
// Click Images filter tab
|
||||
const user = userEvent.setup();
|
||||
const imageTab = screen.getByRole('button', { name: /^images$/i });
|
||||
await user.click(imageTab);
|
||||
|
||||
// Only photo should be visible
|
||||
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
|
||||
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => {
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Find the star button by its title
|
||||
const starBtn = screen.getByTitle(/star/i);
|
||||
await user.click(starBtn);
|
||||
|
||||
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
|
||||
// filesApi.list is mocked — configure it to return trash files when called with trash=true
|
||||
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
return Promise.resolve({ files: [] });
|
||||
});
|
||||
|
||||
render(<FileManager {...defaultProps} files={[]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Click trash toggle button
|
||||
const trashBtn = screen.getByText(/trash/i);
|
||||
await user.click(trashBtn);
|
||||
|
||||
// Trashed file should appear
|
||||
await screen.findByText('old.pdf');
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-007: restore button calls filesApi.restore', async () => {
|
||||
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
return Promise.resolve({ files: [] });
|
||||
});
|
||||
|
||||
render(<FileManager {...defaultProps} files={[]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open trash
|
||||
const trashBtn = screen.getByText(/trash/i);
|
||||
await user.click(trashBtn);
|
||||
await screen.findByText('old.pdf');
|
||||
|
||||
// Click restore button
|
||||
const restoreBtn = screen.getByTitle(/restore/i);
|
||||
await user.click(restoreBtn);
|
||||
|
||||
expect(filesApi.restore).toHaveBeenCalledWith(1, 5);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-008: permanent delete calls filesApi.permanentDelete after confirm', async () => {
|
||||
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
return Promise.resolve({ files: [] });
|
||||
});
|
||||
|
||||
render(<FileManager {...defaultProps} files={[]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open trash
|
||||
await user.click(screen.getByText(/trash/i));
|
||||
await screen.findByText('old.pdf');
|
||||
|
||||
// Click permanent delete (the Trash2 icon button in trash view)
|
||||
const deleteBtn = screen.getByTitle(/delete/i);
|
||||
await user.click(deleteBtn);
|
||||
|
||||
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 5);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-009: empty trash calls filesApi.emptyTrash', async () => {
|
||||
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
|
||||
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
|
||||
return Promise.resolve({ files: [] });
|
||||
});
|
||||
|
||||
render(<FileManager {...defaultProps} files={[]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open trash
|
||||
await user.click(screen.getByText(/trash/i));
|
||||
await screen.findByText('old.pdf');
|
||||
|
||||
// Click "Empty Trash" button
|
||||
const emptyTrashBtn = await screen.findByText(/empty trash/i);
|
||||
await user.click(emptyTrashBtn);
|
||||
|
||||
expect(filesApi.emptyTrash).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-010: image file click opens lightbox', async () => {
|
||||
const files = [
|
||||
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Click the file name to open lightbox
|
||||
await user.click(screen.getByText('photo.jpg'));
|
||||
|
||||
// Lightbox should appear — it has a fixed position overlay with the filename and a counter
|
||||
await waitFor(() => {
|
||||
// The lightbox header shows the filename and "1 / 1"
|
||||
expect(screen.getByText('1 / 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-011: escape key closes lightbox', async () => {
|
||||
const files = [
|
||||
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open lightbox
|
||||
await user.click(screen.getByText('photo.jpg'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 / 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Press Escape
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
// Lightbox should be gone
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-013: soft-delete button calls onDelete', async () => {
|
||||
const onDelete = vi.fn().mockResolvedValue(undefined);
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} onDelete={onDelete} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// The delete (trash) button on a non-trash row is titled 'Delete'
|
||||
const deleteBtn = screen.getByTitle(/delete/i);
|
||||
await user.click(deleteBtn);
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-014: PDF file click opens preview modal', async () => {
|
||||
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'report.pdf' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Click the file name — for a non-image this opens the PDF preview modal
|
||||
await user.click(screen.getByText('report.pdf'));
|
||||
|
||||
// PDF preview modal should appear with the filename in the header
|
||||
await waitFor(() => {
|
||||
// The preview modal header shows the filename
|
||||
const headers = screen.getAllByText('report.pdf');
|
||||
expect(headers.length).toBeGreaterThanOrEqual(2); // in list + in modal header
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
|
||||
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
|
||||
// The AvatarChip shows the first letter of the name
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-016: multiple images in lightbox shows thumbnail strip', async () => {
|
||||
const files = [
|
||||
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo1.jpg' }),
|
||||
buildFile({ id: 2, mime_type: 'image/jpeg', original_name: 'photo2.jpg' }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open lightbox on first image
|
||||
await user.click(screen.getByText('photo1.jpg'));
|
||||
|
||||
// Lightbox shows "1 / 2" counter
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 / 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-017: file size is displayed', () => {
|
||||
const files = [buildFile({ file_size: 51200 })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
expect(screen.getByText('50.0 KB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
|
||||
const files = [
|
||||
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
|
||||
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// The starred filter tab only appears when there are starred files
|
||||
const starredTab = screen.getByRole('button', { name: '' }); // Star icon button in filter tabs
|
||||
await user.click(starredTab);
|
||||
|
||||
expect(screen.getByText('starred.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByText('normal.pdf')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-019: clicking assign button opens assign modal', async () => {
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Pencil/assign button
|
||||
const assignBtn = screen.getByTitle(/assign/i);
|
||||
await user.click(assignBtn);
|
||||
|
||||
// Assign modal should appear (it has a title and a close button)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/assign/i, { selector: 'div' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-020: assign modal shows places list', async () => {
|
||||
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Eiffel Tower' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const assignBtn = screen.getByTitle(/assign/i);
|
||||
await user.click(assignBtn);
|
||||
|
||||
await screen.findByText('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-021: file description is shown when present', () => {
|
||||
const files = [buildFile({ description: 'A very important document' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
expect(screen.getByText('A very important document')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-022: PDF preview modal can be closed', async () => {
|
||||
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'report.pdf' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open preview
|
||||
await user.click(screen.getByText('report.pdf'));
|
||||
|
||||
// Multiple 'report.pdf' elements now (list + modal header)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('report.pdf').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Close via X button in the modal (second X button — first might be something else)
|
||||
const closeButtons = screen.getAllByRole('button', { name: '' });
|
||||
// Find a close button near the modal header — click the last X-like button
|
||||
const xBtn = closeButtons.find(btn => btn.closest('[style*="z-index: 10000"]'));
|
||||
if (xBtn) await user.click(xBtn);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const assignBtn = screen.getByTitle(/assign/i);
|
||||
await user.click(assignBtn);
|
||||
|
||||
await screen.findByText('Hotel Paris');
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
|
||||
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Louvre Museum' });
|
||||
const file = buildFile({ id: 1 });
|
||||
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
render(<FileManager {...defaultProps} files={[file]} places={[place]} onUpdate={onUpdate} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open assign modal
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Louvre Museum');
|
||||
|
||||
// Click on the place button to link it
|
||||
await user.click(screen.getByText('Louvre Museum'));
|
||||
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 });
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
|
||||
const file = buildFile({ id: 1 });
|
||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open assign modal
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Train Ticket');
|
||||
|
||||
// Click on the reservation button to link it
|
||||
await user.click(screen.getByText('Train Ticket'));
|
||||
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 });
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
||||
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Notre Dame' });
|
||||
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Notre Dame');
|
||||
await screen.findByText('Airbnb');
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-027: paste event uploads file when user can upload', async () => {
|
||||
const onUpload = vi.fn().mockResolvedValue({ file: { id: 55 } });
|
||||
render(<FileManager {...defaultProps} onUpload={onUpload} />);
|
||||
|
||||
const container = document.querySelector('.flex.flex-col') as HTMLElement;
|
||||
const file = new File(['data'], 'pasted.png', { type: 'image/png' });
|
||||
|
||||
// Manually build a paste event with a mock clipboardData.items
|
||||
const mockItem = { kind: 'file', getAsFile: () => file };
|
||||
const pasteEvent = new Event('paste', { bubbles: true });
|
||||
Object.defineProperty(pasteEvent, 'clipboardData', {
|
||||
value: { items: [mockItem] },
|
||||
});
|
||||
|
||||
await fireEvent(container, pasteEvent);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-028: upload with places open assign modal after upload', async () => {
|
||||
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Sagrada Familia' });
|
||||
const onUpload = vi.fn().mockResolvedValue({ file: { id: 77 } });
|
||||
|
||||
render(<FileManager {...defaultProps} onUpload={onUpload} places={[place]} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
await userEvent.upload(input, file);
|
||||
|
||||
// After successful upload with places present, assign modal opens
|
||||
await waitFor(() => {
|
||||
expect(onUpload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-029: assign modal with days+assignments shows day group', async () => {
|
||||
const { buildPlace, buildDay } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Arc de Triomphe' });
|
||||
const day = buildDay({ id: 5, date: '2025-06-01', day_number: 1 });
|
||||
const assignments = { '5': [{ id: 1, day_id: 5, place_id: 10, order_index: 0, place }] };
|
||||
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} days={[day]} assignments={assignments} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Arc de Triomphe');
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-030: file with linked place shows source badge', async () => {
|
||||
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Colosseum' });
|
||||
const file = buildFile({ place_id: 10 });
|
||||
|
||||
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
|
||||
|
||||
// Source badge text includes place name
|
||||
await screen.findByText(/Colosseum/);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
|
||||
const { buildPlace } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Venice Beach' });
|
||||
// File already has place_id set to 10 (linked)
|
||||
const file = buildFile({ id: 1, place_id: 10 });
|
||||
|
||||
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Open assign modal
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Venice Beach');
|
||||
|
||||
// Clicking the linked place should unlink it
|
||||
await user.click(screen.getByText('Venice Beach'));
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
|
||||
// File already has reservation_id set to 20
|
||||
const file = buildFile({ id: 1, reservation_id: 20 });
|
||||
|
||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Museum Pass');
|
||||
|
||||
// Clicking the linked reservation should unlink it
|
||||
await user.click(screen.getByText('Museum Pass'));
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
|
||||
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'doc.pdf' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByText('doc.pdf'));
|
||||
|
||||
// Modal opens (multiple occurrences of doc.pdf)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('doc.pdf').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Click the backdrop to close
|
||||
const backdrop = document.querySelector('[style*="z-index: 10000"]') as HTMLElement;
|
||||
if (backdrop) await user.click(backdrop);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('doc.pdf').length).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-012: upload via dropzone calls onUpload', async () => {
|
||||
const onUpload = vi.fn().mockResolvedValue({ file: { id: 99 } });
|
||||
render(<FileManager {...defaultProps} onUpload={onUpload} />);
|
||||
|
||||
// Find the hidden file input from the dropzone
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
const file = new File(['hello'], 'test.pdf', { type: 'application/pdf' });
|
||||
|
||||
await userEvent.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpload).toHaveBeenCalled();
|
||||
const call = onUpload.mock.calls[0];
|
||||
expect(call[0]).toBeInstanceOf(FormData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
@@ -10,6 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
@@ -30,6 +31,10 @@ function formatSize(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function triggerDownload(url: string, filename: string) {
|
||||
downloadFile(url, filename).catch(() => {})
|
||||
}
|
||||
|
||||
function formatDateWithLocale(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
@@ -89,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
@@ -108,11 +113,17 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(file.url, file.original_name)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.download') || 'Download'}>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -514,6 +525,10 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
@@ -634,8 +649,17 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
{dayGroups.map(({ day, dayPlaces }) => (
|
||||
<div key={day.id}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
||||
{(() => {
|
||||
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
|
||||
return badge ? (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
|
||||
}}>{badge}</span>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
{dayPlaces.map(placeBtn)}
|
||||
</div>
|
||||
@@ -728,12 +752,19 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
@@ -749,7 +780,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
|
||||
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
@@ -757,25 +788,81 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
{showTrash
|
||||
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
|
||||
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={toggleTrash} style={{
|
||||
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
|
||||
fontFamily: 'inherit',
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
|
||||
</button>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
|
||||
</h2>
|
||||
|
||||
{!showTrash && (
|
||||
<>
|
||||
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||
{[
|
||||
{ id: 'all', label: t('files.filterAll') },
|
||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
|
||||
{ id: 'pdf', label: t('files.filterPdf') },
|
||||
{ id: 'image', label: t('files.filterImages') },
|
||||
{ id: 'doc', label: t('files.filterDocs') },
|
||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||
].map(tab => {
|
||||
const active = filterType === tab.id
|
||||
const TabIcon = 'icon' in tab ? tab.icon : null
|
||||
const count = tab.id === 'all' ? files.length
|
||||
: tab.id === 'starred' ? files.filter(f => f.starred).length
|
||||
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
|
||||
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
|
||||
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
|
||||
: tab.id === 'collab' ? files.filter(f => f.note_id).length
|
||||
: 0
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--bg-card)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: active ? 500 : 400,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
|
||||
{'label' in tab && tab.label}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button onClick={toggleTrash} style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
flexShrink: 0, marginLeft: 'auto',
|
||||
opacity: showTrash ? 1 : 0.88,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTrash ? (
|
||||
@@ -813,7 +900,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{can('file_upload', trip) && <div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||
@@ -838,7 +925,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'all', label: t('files.filterAll') },
|
||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
||||
@@ -861,7 +948,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||
{filteredFiles.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import JournalBody from './JournalBody';
|
||||
|
||||
describe('JournalBody', () => {
|
||||
it('FE-COMP-JOURNALBODY-001: renders plain text content', () => {
|
||||
render(<JournalBody text="Hello traveller" />);
|
||||
expect(screen.getByText('Hello traveller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-002: renders bold markdown as <strong>', () => {
|
||||
const { container } = render(<JournalBody text="This is **bold** text" />);
|
||||
const strong = container.querySelector('strong');
|
||||
expect(strong).toBeInTheDocument();
|
||||
expect(strong!.textContent).toBe('bold');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => {
|
||||
render(<JournalBody text="[Visit](https://example.com)" />);
|
||||
const link = screen.getByRole('link', { name: 'Visit' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
|
||||
const { container } = render(<JournalBody text="## Section Title" />);
|
||||
const p = container.querySelector('p');
|
||||
expect(p).toBeInTheDocument();
|
||||
expect(p!.textContent).toBe('Section Title');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => {
|
||||
const { container } = render(<JournalBody text="" />);
|
||||
expect(container.querySelector('.journal-body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
export default function JournalBody({ text, dark }: Props) {
|
||||
return (
|
||||
<div className="journal-body" style={{
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 1.6,
|
||||
color: 'inherit',
|
||||
}}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderLeft: `3px solid var(--journal-accent)`,
|
||||
paddingLeft: 16, margin: '12px 0',
|
||||
fontStyle: 'italic', color: 'var(--journal-muted)',
|
||||
}}>{children}</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ul>,
|
||||
ol: ({ children }) => <ol style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ol>,
|
||||
li: ({ children }) => <li style={{ margin: '4px 0' }}>{children}</li>,
|
||||
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.includes('language-')
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre style={{
|
||||
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
|
||||
borderRadius: 8, padding: 14, overflowX: 'auto',
|
||||
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
|
||||
}}>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code style={{
|
||||
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
|
||||
borderRadius: 4, padding: '2px 5px', fontSize: '0.9em', fontFamily: 'monospace',
|
||||
}}>{children}</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-006
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
// Leaflet does not work in jsdom — mock the entire library
|
||||
vi.mock('leaflet', () => {
|
||||
const mockMarker = {
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
bindTooltip: vi.fn().mockReturnThis(),
|
||||
on: vi.fn().mockReturnThis(),
|
||||
setIcon: vi.fn(),
|
||||
setZIndexOffset: vi.fn(),
|
||||
getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
};
|
||||
const mockMap = {
|
||||
remove: vi.fn(),
|
||||
invalidateSize: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
};
|
||||
return {
|
||||
default: {
|
||||
map: vi.fn(() => mockMap),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
marker: vi.fn(() => mockMarker),
|
||||
polyline: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
},
|
||||
map: vi.fn(() => mockMap),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
marker: vi.fn(() => mockMarker),
|
||||
polyline: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { buildSettings } from '../../../tests/helpers/factories';
|
||||
import L from 'leaflet';
|
||||
import JourneyMap from './JourneyMap';
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
|
||||
const entriesWithCoords = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
|
||||
];
|
||||
|
||||
const entriesWithoutCoords = [
|
||||
{ id: 'e3', lat: 0, lng: 0, title: 'Unknown Place', mood: null, entry_date: '2025-06-03' },
|
||||
];
|
||||
|
||||
const mixedEntries = [
|
||||
...entriesWithCoords,
|
||||
...entriesWithoutCoords,
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('JourneyMap', () => {
|
||||
it('FE-COMP-JOURNEYMAP-001: renders map container', () => {
|
||||
const { container } = render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// The component renders a div with a child div ref for the Leaflet map
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
expect(L.map).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-002: renders markers for entries with coordinates', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// Two entries with valid lat/lng should produce two markers
|
||||
expect(L.marker).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-003: does not render markers for entries without coordinates', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithoutCoords} />
|
||||
);
|
||||
// Entry with lat=0 and lng=0 is filtered out by buildMarkerItems (if (e.lat && e.lng))
|
||||
expect(L.marker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-004: renders polyline connecting entries', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// With 2+ marker items, a route polyline is drawn
|
||||
expect(L.polyline).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-005: shows entry title in marker tooltip', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// Each marker calls bindTooltip with the entry label
|
||||
const mockMarkerInstance = (L.marker as any).mock.results[0].value;
|
||||
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith(
|
||||
'Paris',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(
|
||||
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
expect(ref.current).not.toBeNull();
|
||||
expect(typeof ref.current!.focusMarker).toBe('function');
|
||||
expect(typeof ref.current!.highlightMarker).toBe('function');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-007: renders SVG pin markers via divIcon', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// Each marker is created with L.divIcon containing SVG html
|
||||
expect(L.divIcon).toHaveBeenCalledTimes(2);
|
||||
const firstCall = (L.divIcon as any).mock.calls[0][0];
|
||||
expect(firstCall.html).toContain('<svg');
|
||||
expect(firstCall.html).toContain('</svg>');
|
||||
// Marker index label "1" for first entry
|
||||
expect(firstCall.html).toContain('>1<');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => {
|
||||
const entriesWithMood = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' },
|
||||
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Sad Berlin', mood: 'sad', entry_date: '2025-06-02' },
|
||||
];
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithMood} />
|
||||
);
|
||||
// Markers are still created (mood does not prevent rendering)
|
||||
expect(L.marker).toHaveBeenCalledTimes(2);
|
||||
// Tooltips use the entry titles
|
||||
const mockMarker1 = (L.marker as any).mock.results[0].value;
|
||||
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith(
|
||||
'Happy Paris',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
const mockMarker2 = (L.marker as any).mock.results[1].value;
|
||||
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith(
|
||||
'Sad Berlin',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-009: draws route polyline connecting multiple markers', () => {
|
||||
const threeEntries = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
|
||||
{ id: 'e3', lat: 41.9028, lng: 12.4964, title: 'Rome', mood: null, entry_date: '2025-06-03' },
|
||||
];
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={threeEntries} />
|
||||
);
|
||||
// Route polyline is drawn for items.length > 1
|
||||
expect(L.polyline).toHaveBeenCalled();
|
||||
const polylineCall = (L.polyline as any).mock.calls[0];
|
||||
// Should contain coordinates for all three entries
|
||||
expect(polylineCall[0].length).toBe(3);
|
||||
// Verify dashed style
|
||||
expect(polylineCall[1]).toMatchObject({ dashArray: '4 6' });
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-010: fitBounds is called for auto-zoom', () => {
|
||||
// Trigger requestAnimationFrame synchronously
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
|
||||
const mockMap = (L.map as any).mock.results[0].value;
|
||||
// fitBounds is called inside requestAnimationFrame with the collected coordinates
|
||||
expect(mockMap.fitBounds).toHaveBeenCalled();
|
||||
expect(L.latLngBounds).toHaveBeenCalled();
|
||||
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-011: single entry creates marker but no polyline', () => {
|
||||
const singleEntry = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Solo Paris', mood: null, entry_date: '2025-06-01' },
|
||||
];
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={singleEntry} />
|
||||
);
|
||||
// One marker created
|
||||
expect(L.marker).toHaveBeenCalledTimes(1);
|
||||
// No route polyline — polyline is only drawn when items.length > 1
|
||||
expect(L.polyline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-012: renders zoom control buttons', () => {
|
||||
const { container } = render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// The component renders zoom in (+) and zoom out (−) buttons
|
||||
const buttons = container.querySelectorAll('button');
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons[0].textContent).toBe('+');
|
||||
expect(buttons[1].textContent).toBe('−');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
label: string
|
||||
mood?: string | null
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
}
|
||||
|
||||
export interface JourneyMapHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
invalidateSize: () => void
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
checkins: any[]
|
||||
entries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
height?: number
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
}
|
||||
|
||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
const items: MapMarkerItem[] = []
|
||||
for (const e of entries) {
|
||||
if (e.lat && e.lng) {
|
||||
items.push({
|
||||
id: e.id,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
label: e.title || 'Entry',
|
||||
mood: e.mood,
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => a.time.localeCompare(b.time))
|
||||
return items
|
||||
}
|
||||
|
||||
const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||
const shadow = highlighted
|
||||
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const label = String(dayLabel)
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
|
||||
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
||||
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
||||
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||
</svg>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
||||
const itemsRef = useRef<MapMarkerItem[]>([])
|
||||
const highlightedRef = useRef<string | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
|
||||
const highlightMarker = useCallback((id: string | null) => {
|
||||
const prev = highlightedRef.current
|
||||
highlightedRef.current = id
|
||||
const isDark = !!darkRef.current
|
||||
|
||||
if (prev && prev !== id) {
|
||||
const marker = markersRef.current.get(prev)
|
||||
const item = itemsRef.current.find(i => i.id === prev)
|
||||
if (marker && item) {
|
||||
marker.setIcon(L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||
}))
|
||||
marker.setZIndexOffset(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const marker = markersRef.current.get(id)
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (marker && item) {
|
||||
marker.setIcon(L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(item.dayColor, item.dayLabel, true),
|
||||
}))
|
||||
marker.setZIndexOffset(1000)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (marker && mapRef.current) {
|
||||
try {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
} catch { /* map not yet initialized */ }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const invalidateSize = useCallback(() => {
|
||||
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove()
|
||||
mapRef.current = null
|
||||
}
|
||||
markersRef.current.clear()
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: fullScreen ? true : false,
|
||||
dragging: true,
|
||||
touchZoom: true,
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
const defaultTile = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||||
L.tileLayer(mapTileUrl || defaultTile, {
|
||||
maxZoom: 18,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
// Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle
|
||||
// before loading tiles). On the journey mobile combined view we flyTo
|
||||
// constantly when switching cards, so tiles lag visibly — force eager
|
||||
// updates and keep a larger ring of off-screen tiles ready.
|
||||
updateWhenIdle: false,
|
||||
keepBuffer: 4,
|
||||
} as any).addTo(map)
|
||||
|
||||
const items = buildMarkerItems(entries)
|
||||
itemsRef.current = items
|
||||
|
||||
const allCoords: L.LatLngTuple[] = []
|
||||
|
||||
if (stableTrail.length > 1) {
|
||||
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
||||
L.polyline(coords, {
|
||||
color: '#6366f1', weight: 3, opacity: 0.4,
|
||||
dashArray: '6 4', lineCap: 'round',
|
||||
}).addTo(map)
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
}
|
||||
|
||||
// route polyline — only in non-fullscreen (sidebar map) mode
|
||||
if (!fullScreen && items.length > 1) {
|
||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||
L.polyline(routeCoords, {
|
||||
color: dark ? '#71717A' : '#A1A1AA',
|
||||
weight: 1.5,
|
||||
opacity: 0.5,
|
||||
dashArray: '4 6',
|
||||
lineCap: 'round', lineJoin: 'round',
|
||||
}).addTo(map)
|
||||
}
|
||||
|
||||
// place markers
|
||||
items.forEach((item, i) => {
|
||||
const pos: L.LatLngTuple = [item.lat, item.lng]
|
||||
allCoords.push(pos)
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||
})
|
||||
|
||||
const marker = L.marker(pos, { icon }).addTo(map)
|
||||
marker.bindTooltip(item.label, {
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip',
|
||||
})
|
||||
|
||||
marker.on('click', () => {
|
||||
onMarkerClickRef.current?.(item.id)
|
||||
})
|
||||
|
||||
markersRef.current.set(item.id, marker)
|
||||
})
|
||||
|
||||
// fit bounds
|
||||
requestAnimationFrame(() => {
|
||||
if (!mapRef.current) return
|
||||
try {
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
const pb = paddingBottom || 50
|
||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (mapRef.current) map.invalidateSize()
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
if (!activeMarkerId || !mapRef.current) return
|
||||
// small delay to ensure markers are rendered after map build
|
||||
const timer = setTimeout(() => {
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (!marker || !mapRef.current) return
|
||||
// fitBounds may still be pending when this fires — getZoom() throws
|
||||
// "Set map center and zoom first" until the map has a view. Guard it.
|
||||
try {
|
||||
const currentZoom = mapRef.current.getZoom()
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
|
||||
} catch {
|
||||
mapRef.current.setView(marker.getLatLng(), 12)
|
||||
}
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeMarkerId])
|
||||
|
||||
const zoomIn = () => mapRef.current?.zoomIn()
|
||||
const zoomOut = () => mapRef.current?.zoomOut()
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||
color: dark ? '#fff' : '#18181B',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||
}}
|
||||
>+</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||
color: dark ? '#fff' : '#18181B',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||
}}
|
||||
>−</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default JourneyMap
|
||||
@@ -0,0 +1,57 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
|
||||
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
|
||||
|
||||
// Unified handle — both providers expose the same three methods.
|
||||
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
checkins: unknown[]
|
||||
entries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
height?: number
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
}
|
||||
|
||||
const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyMapAuto(props, ref) {
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
const leafletRef = useRef<JourneyMapHandle>(null)
|
||||
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||
// supplied a token yet — otherwise the map would just show a stub.
|
||||
const useGL = provider === 'mapbox-gl' && !!token
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||
focusMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.focusMarker(id),
|
||||
invalidateSize: () => (useGL ? glRef.current : leafletRef.current)?.invalidateSize(),
|
||||
}), [useGL])
|
||||
|
||||
if (useGL) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <JourneyMapGL ref={glRef} {...(props as any)} />
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <JourneyMap ref={leafletRef} {...(props as any)} />
|
||||
})
|
||||
|
||||
export default JourneyMapAuto
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
|
||||
export interface JourneyMapGLHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
invalidateSize: () => void
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
checkins: unknown[]
|
||||
entries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
height?: number
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
label: string
|
||||
locationName: string
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
}
|
||||
|
||||
const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function buildItems(entries: MapEntry[]): Item[] {
|
||||
const items: Item[] = []
|
||||
for (const e of entries) {
|
||||
if (e.lat && e.lng) {
|
||||
items.push({
|
||||
id: e.id,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
label: e.title || '',
|
||||
locationName: e.location_name || '',
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => a.time.localeCompare(b.time))
|
||||
return items
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function formatEntryDate(iso: string): string {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
// Inject the popup styles once per document. Two-line frosted-glass card in
|
||||
// the Apple/Google Maps idiom — title on top, location / date subtly below.
|
||||
function ensureJourneyPopupStyle() {
|
||||
if (document.getElementById('trek-journey-popup-style')) return
|
||||
const s = document.createElement('style')
|
||||
s.id = 'trek-journey-popup-style'
|
||||
s.textContent = `
|
||||
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
|
||||
padding: 9px 14px 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
font-family: -apple-system, system-ui, sans-serif;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
|
||||
background: rgba(24, 24, 27, 0.88);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: #FAFAFA;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
|
||||
border-top-color: rgba(255, 255, 255, 0.94);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
|
||||
border-top-color: rgba(24, 24, 27, 0.88);
|
||||
border-bottom-color: rgba(24, 24, 27, 0.88);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
||||
.trek-journey-popup-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #18181B;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||
.trek-journey-popup-sub {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: #71717A;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||
.trek-journey-popup-place {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.trek-journey-popup-sep {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.55;
|
||||
font-weight: 500;
|
||||
}
|
||||
.trek-journey-popup-date { flex: 0 0 auto; }
|
||||
@keyframes trek-journey-popup-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||
const fill = dayColor
|
||||
const textColor = '#fff'
|
||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||
const shadow = highlighted
|
||||
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
const label = String(dayLabel)
|
||||
|
||||
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||
// the CSS transition would catch the map's per-frame translate updates and
|
||||
// the marker smears all over the viewport while scrolling / flying.
|
||||
const wrap = document.createElement('div')
|
||||
wrap.style.cssText = `width:${MARKER_W}px;height:${MARKER_H}px;cursor:pointer;`
|
||||
const inner = document.createElement('div')
|
||||
inner.className = 'trek-journey-marker-inner'
|
||||
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
||||
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||
</svg>`
|
||||
wrap.appendChild(inner)
|
||||
return wrap
|
||||
}
|
||||
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
||||
const itemsRef = useRef<Item[]>([])
|
||||
const highlightedRef = useRef<string | null>(null)
|
||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
|
||||
const showPopup = useCallback((id: string) => {
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (!item || !mapRef.current) return
|
||||
ensureJourneyPopupStyle()
|
||||
// Primary line: user-given title. If none, fall back to the location
|
||||
// name so we always show *something* useful on the top line.
|
||||
const primaryRaw = item.label || item.locationName || 'Entry'
|
||||
const secondaryPlace = item.label ? item.locationName : ''
|
||||
const dateStr = formatEntryDate(item.time)
|
||||
const primary = escapeHtml(primaryRaw)
|
||||
const place = escapeHtml(secondaryPlace)
|
||||
const date = escapeHtml(dateStr)
|
||||
|
||||
const subParts: string[] = []
|
||||
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`)
|
||||
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`)
|
||||
const subline = subParts.length === 2
|
||||
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
|
||||
: subParts.join('')
|
||||
|
||||
const html = `
|
||||
<div class="trek-journey-popup-title">${primary}</div>
|
||||
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
|
||||
`
|
||||
// Marker is bottom-anchored with a visible height of 36px (1.2× on
|
||||
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
|
||||
const offset: [number, number] = [0, -46]
|
||||
if (popupRef.current) {
|
||||
popupRef.current.setLngLat([item.lng, item.lat])
|
||||
popupRef.current.setHTML(html)
|
||||
popupRef.current.setOffset(offset)
|
||||
const el = popupRef.current.getElement()
|
||||
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||||
} else {
|
||||
popupRef.current = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
closeOnMove: false,
|
||||
anchor: 'bottom',
|
||||
offset,
|
||||
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
|
||||
maxWidth: '280px',
|
||||
})
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.setHTML(html)
|
||||
.addTo(mapRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hidePopup = useCallback(() => {
|
||||
if (popupRef.current) {
|
||||
try { popupRef.current.remove() } catch { /* noop */ }
|
||||
popupRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setMarkerStyle = useCallback((id: string, highlighted: boolean) => {
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (!item || !marker) return
|
||||
const el = marker.getElement()
|
||||
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||
if (!currentInner) return
|
||||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||
// would wipe mapbox's positional transform and make the marker flicker.
|
||||
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
|
||||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||
currentInner.style.cssText = nextInner.style.cssText
|
||||
currentInner.innerHTML = nextInner.innerHTML
|
||||
el.style.zIndex = highlighted ? '1000' : '0'
|
||||
}, [])
|
||||
|
||||
const highlightMarker = useCallback((id: string | null) => {
|
||||
const prev = highlightedRef.current
|
||||
highlightedRef.current = id
|
||||
if (prev && prev !== id) setMarkerStyle(prev, false)
|
||||
if (id) {
|
||||
setMarkerStyle(id, true)
|
||||
showPopup(id)
|
||||
} else {
|
||||
hidePopup()
|
||||
}
|
||||
}, [setMarkerStyle, showPopup, hidePopup])
|
||||
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (!marker || !mapRef.current) return
|
||||
try {
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 600,
|
||||
})
|
||||
} catch { /* map not yet ready */ }
|
||||
}, [highlightMarker, mapbox3d])
|
||||
|
||||
const invalidateSize = useCallback(() => {
|
||||
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [highlightMarker, focusMarker, invalidateSize])
|
||||
|
||||
// Build map once per style/token change. Markers and layers are rebuilt
|
||||
// inside the same effect so they stay in sync with the active style.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const items = buildItems(entries)
|
||||
itemsRef.current = items
|
||||
|
||||
const bounds = new mapboxgl.LngLatBounds()
|
||||
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
||||
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||
const hasPoints = items.length > 0 || stableTrail.length > 0
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: containerRef.current,
|
||||
style: mapboxStyle,
|
||||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||
zoom: hasPoints ? 2 : 1,
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||
}
|
||||
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
||||
// stay pinned to their coordinates at every zoom and pitch.
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
|
||||
// route trail — dashed line connecting entries in time order
|
||||
if (items.length > 1) {
|
||||
const coords = items.map(i => [i.lng, i.lat])
|
||||
if (map.getSource('journey-route')) (map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({
|
||||
type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
|
||||
})
|
||||
else {
|
||||
map.addSource('journey-route', {
|
||||
type: 'geojson',
|
||||
data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'journey-route-line',
|
||||
type: 'line',
|
||||
source: 'journey-route',
|
||||
paint: {
|
||||
'line-color': darkRef.current ? '#71717A' : '#A1A1AA',
|
||||
'line-width': 1.5,
|
||||
'line-opacity': 0.5,
|
||||
'line-dasharray': [2, 3],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// markers
|
||||
items.forEach((item) => {
|
||||
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.addTo(map)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
onMarkerClickRef.current?.(item.id)
|
||||
})
|
||||
markersRef.current.set(item.id, marker)
|
||||
})
|
||||
|
||||
// fit bounds to all points
|
||||
if (hasPoints) {
|
||||
const pb = paddingBottom || 50
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
||||
maxZoom: 16,
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 0,
|
||||
})
|
||||
} catch { /* empty bounds */ }
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (popupRef.current) {
|
||||
try { popupRef.current.remove() } catch { /* noop */ }
|
||||
popupRef.current = null
|
||||
}
|
||||
highlightedRef.current = null
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
|
||||
// external activeMarkerId → highlight + flyTo
|
||||
useEffect(() => {
|
||||
if (!activeMarkerId || !mapRef.current) return
|
||||
const t = setTimeout(() => {
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (!marker || !mapRef.current) return
|
||||
try {
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 500,
|
||||
})
|
||||
} catch { /* map not ready */ }
|
||||
}, 50)
|
||||
return () => clearTimeout(t)
|
||||
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
||||
|
||||
if (!mapboxToken) {
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
||||
className="flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6"
|
||||
>
|
||||
<div className="text-sm text-zinc-500">
|
||||
No Mapbox access token configured.<br />
|
||||
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default JourneyMapGL
|
||||
@@ -0,0 +1,72 @@
|
||||
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import MarkdownToolbar from './MarkdownToolbar';
|
||||
import React from 'react';
|
||||
|
||||
function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.selectionStart = selectionStart;
|
||||
textarea.selectionEnd = selectionEnd;
|
||||
textarea.focus = vi.fn();
|
||||
textarea.setSelectionRange = vi.fn();
|
||||
return { current: textarea } as React.RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
describe('MarkdownToolbar', () => {
|
||||
let onUpdate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onUpdate = vi.fn();
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||
const ref = createTextareaRef();
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-002: buttons have correct title labels', () => {
|
||||
const ref = createTextareaRef();
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
expect(screen.getByTitle('Bold')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Italic')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Link')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Heading')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Quote')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('List')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Ordered')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-003: bold button wraps selected text with **', () => {
|
||||
const ref = createTextareaRef('hello world', 6, 11);
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Bold'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('hello **world**');
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-004: italic button wraps selected text with _', () => {
|
||||
const ref = createTextareaRef('hello world', 6, 11);
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Italic'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('hello _world_');
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-005: link button wraps selected text as markdown link', () => {
|
||||
const ref = createTextareaRef('click me', 0, 8);
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Link'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('[click me](url)');
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-006: heading button inserts line prefix', () => {
|
||||
const ref = createTextareaRef('my title', 0, 0);
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Heading'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('## my title');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
onUpdate: (value: string) => void
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string }
|
||||
|
||||
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
|
||||
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
|
||||
{ icon: Italic, label: 'Italic', action: { type: 'wrap', before: '_', after: '_' } },
|
||||
{ icon: Heading2, label: 'Heading', action: { type: 'line', prefix: '## ' } },
|
||||
{ icon: Quote, label: 'Quote', action: { type: 'line', prefix: '> ' } },
|
||||
{ icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } },
|
||||
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
|
||||
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } },
|
||||
{ icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } },
|
||||
]
|
||||
|
||||
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
|
||||
const apply = (action: FormatAction) => {
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
|
||||
const start = ta.selectionStart
|
||||
const end = ta.selectionEnd
|
||||
const text = ta.value
|
||||
const selected = text.slice(start, end)
|
||||
|
||||
let result: string
|
||||
let cursorPos: number
|
||||
|
||||
if (action.type === 'wrap') {
|
||||
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
|
||||
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length
|
||||
} else if (action.type === 'insert') {
|
||||
result = text.slice(0, start) + action.text + text.slice(end)
|
||||
cursorPos = start + action.text.length
|
||||
} else {
|
||||
// line prefix — find start of current line
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1
|
||||
result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart)
|
||||
cursorPos = start + action.prefix.length
|
||||
}
|
||||
|
||||
onUpdate(result)
|
||||
|
||||
// restore cursor after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(cursorPos, cursorPos)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 2, padding: '6px 4px',
|
||||
borderBottom: `1px solid var(--journal-border)`,
|
||||
overflowX: 'auto',
|
||||
}}>
|
||||
{ACTIONS.map(a => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
title={a.label}
|
||||
onClick={() => apply(a.action)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 6,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none',
|
||||
color: 'var(--journal-muted)', cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
<a.icon size={15} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
amazing: Laugh,
|
||||
good: Smile,
|
||||
neutral: Meh,
|
||||
rough: Frown,
|
||||
}
|
||||
|
||||
const MOOD_COLORS: Record<string, string> = {
|
||||
amazing: 'text-pink-500',
|
||||
good: 'text-amber-500',
|
||||
neutral: 'text-zinc-400',
|
||||
rough: 'text-violet-500',
|
||||
}
|
||||
|
||||
const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||
sunny: Sun,
|
||||
partly: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rainy: CloudRain,
|
||||
stormy: CloudLightning,
|
||||
cold: Snowflake,
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto): string {
|
||||
return `/api/photos/${p.photo_id}/thumbnail`
|
||||
}
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/[#*_~`>\[\]()!|-]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||
dayLabel: number
|
||||
dayColor: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
|
||||
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
|
||||
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
|
||||
|
||||
const thumbSrc = firstPhoto
|
||||
? publicPhotoUrl
|
||||
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
|
||||
: photoUrl(firstPhoto as JourneyPhoto)
|
||||
: null
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
|
||||
isActive
|
||||
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
|
||||
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
|
||||
} backdrop-blur-lg`}
|
||||
>
|
||||
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
|
||||
{/* Photo thumbnail */}
|
||||
{thumbSrc ? (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{hasPhotos && entry.photos!.length > 1 && (
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
|
||||
<Camera size={10} />
|
||||
{entry.photos!.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
|
||||
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||
{/* Day number + date + mood/weather */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
|
||||
{dayLabel}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
|
||||
{MoodIcon && (
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
|
||||
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
|
||||
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
|
||||
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
|
||||
'bg-zinc-100 dark:bg-zinc-700'
|
||||
}`}>
|
||||
<MoodIcon size={11} className={moodColor} />
|
||||
</span>
|
||||
)}
|
||||
{WeatherIcon && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
|
||||
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
|
||||
</h4>
|
||||
|
||||
{/* Story preview (1-2 lines, only on active card) */}
|
||||
{isActive && storyPreview && (
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
|
||||
{storyPreview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
<div className="flex items-center gap-1 mt-auto">
|
||||
{hasLocation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
X, Pencil, Trash2, MapPin, Clock, Camera,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import JournalBody from './JournalBody'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
|
||||
}
|
||||
|
||||
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
sunny: { icon: Sun, label: 'Sunny' },
|
||||
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
|
||||
if (builder) return builder(p.photo_id)
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
}
|
||||
|
||||
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
const prosArr = entry.pros_cons?.pros ?? []
|
||||
const consArr = entry.pros_cons?.cons ?? []
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => { onClose(); onEdit(); }}
|
||||
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
|
||||
{/* Hero photo(s) */}
|
||||
{photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||
<Camera size={12} />
|
||||
{photos.length} photos
|
||||
</div>
|
||||
)}
|
||||
{/* Photo strip for multiple photos */}
|
||||
{photos.length > 1 && (
|
||||
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
|
||||
{photos.map((p, i) => (
|
||||
<img
|
||||
key={p.id || i}
|
||||
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-5 pb-32">
|
||||
|
||||
{/* Date + time + location header */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.location_name && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
{formatLocationName(entry.location_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{entry.title && (
|
||||
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
|
||||
{entry.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Mood + Weather chips */}
|
||||
{(mood || weather) && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
|
||||
<mood.icon size={13} />
|
||||
{mood.label}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<weather.icon size={13} />
|
||||
{weather.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story */}
|
||||
{entry.story && (
|
||||
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
{entry.tags.map((tag, i) => (
|
||||
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pros & Cons */}
|
||||
{hasProscons && (
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
|
||||
{prosArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsUp size={12} /> Pros
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{prosArr.map((p, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-emerald-500 mt-0.5">+</span> {p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{prosArr.length > 0 && consArr.length > 0 && (
|
||||
<div className="border-t border-zinc-200 dark:border-zinc-700" />
|
||||
)}
|
||||
{consArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsDown size={12} /> Cons
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{consArr.map((c, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-red-500 mt-0.5">−</span> {c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import JourneyMap from './JourneyMap'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
import type { JourneyMapHandle } from './JourneyMap'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { DAY_COLORS } from './dayColors'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entries: JourneyEntry[] | any[]
|
||||
mapEntries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
dark?: boolean
|
||||
readOnly?: boolean
|
||||
onEntryClick: (entry: any) => void
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
carouselBottom?: string
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
entries,
|
||||
mapEntries,
|
||||
trail,
|
||||
dark,
|
||||
readOnly,
|
||||
onEntryClick,
|
||||
onAddEntry,
|
||||
publicPhotoUrl,
|
||||
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const entryDayMeta = useMemo(() => {
|
||||
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
|
||||
const counters = new Map<string, number>()
|
||||
return entries.map((e: any) => {
|
||||
const dayIdx = uniqueDates.indexOf(e.entry_date)
|
||||
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
|
||||
counters.set(e.entry_date, dayLabel)
|
||||
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
|
||||
})
|
||||
}, [entries])
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
|
||||
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
|
||||
if (mapEntry) {
|
||||
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
|
||||
} else {
|
||||
try { mapRef.current?.highlightMarker(null) } catch {}
|
||||
}
|
||||
}, [entries, mapEntries])
|
||||
|
||||
// Pick the card that's currently closest to the carousel horizontal center.
|
||||
// More stable than IntersectionObserver thresholds when the active card can
|
||||
// drift toward the viewport edge with proximity snapping.
|
||||
const pickNearestCard = useCallback(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el) return
|
||||
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
|
||||
let bestIdx = 0
|
||||
let bestDist = Infinity
|
||||
cardRefs.current.forEach((node, idx) => {
|
||||
const r = node.getBoundingClientRect()
|
||||
const cardCenter = r.left + r.width / 2
|
||||
const d = Math.abs(cardCenter - containerCenter)
|
||||
if (d < bestDist) { bestDist = d; bestIdx = idx }
|
||||
})
|
||||
setActiveIndex(prev => {
|
||||
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
|
||||
return bestIdx
|
||||
})
|
||||
}, [syncMapToCarousel])
|
||||
|
||||
// Defer all state updates until scrolling settles — updating activeIndex
|
||||
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el || entries.length === 0) return
|
||||
let settleTimer: number | null = null
|
||||
const onScroll = () => {
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
}
|
||||
}, [entries.length, pickNearestCard])
|
||||
|
||||
// Scroll a given card into the horizontal center of the carousel
|
||||
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||
const card = cardRefs.current.get(idx)
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
// Scroll carousel to entry when map marker is clicked
|
||||
const handleMarkerClick = useCallback((id: string) => {
|
||||
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||
if (idx === -1) return
|
||||
setActiveIndex(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}, [entries, scrollCardIntoCenter])
|
||||
|
||||
// Tap on a card: if it's already active, open the edit view; otherwise
|
||||
// activate + center it first (don't jump straight into the editor).
|
||||
const handleCardTap = useCallback((entry: any, idx: number) => {
|
||||
if (idx === activeIndex) {
|
||||
onEntryClick(entry)
|
||||
} else {
|
||||
setActiveIndex(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}
|
||||
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
|
||||
|
||||
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
const timer = setTimeout(() => syncMapToCarousel(0), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [entries.length])
|
||||
|
||||
const activeEntryId = entries[activeIndex]
|
||||
? String(entries[activeIndex].id)
|
||||
: null
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="fixed left-0 right-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
>
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
trail={trail}
|
||||
height={9999}
|
||||
dark={dark}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
/>
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed left-0 right-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
>
|
||||
{/* Full-screen map */}
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
trail={trail}
|
||||
height={9999}
|
||||
dark={dark}
|
||||
activeMarkerId={activeEntryId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
paddingBottom={200}
|
||||
/>
|
||||
|
||||
{/* Bottom carousel */}
|
||||
<div
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry: any, i: number) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-idx={i}
|
||||
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
||||
style={{ scrollSnapAlign: 'center' }}
|
||||
>
|
||||
<MobileEntryCard
|
||||
entry={entry}
|
||||
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||
isActive={i === activeIndex}
|
||||
onClick={() => handleCardTap(entry, i)}
|
||||
publicPhotoUrl={publicPhotoUrl}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAB: add entry — bottom right, above the timeline carousel */}
|
||||
{!readOnly && onAddEntry && (
|
||||
<div
|
||||
className="fixed right-4 z-30"
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
||||
>
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-008
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import PhotoLightbox from './PhotoLightbox';
|
||||
|
||||
const samplePhotos = [
|
||||
{ id: 'p1', src: '/photos/1.jpg', caption: 'Sunset at the beach' },
|
||||
{ id: 'p2', src: '/photos/2.jpg', caption: 'Mountain trail' },
|
||||
{ id: 'p3', src: '/photos/3.jpg', caption: null },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('PhotoLightbox', () => {
|
||||
it('FE-COMP-LIGHTBOX-001: renders without crashing when open', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-002: shows photo image', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', '/photos/1.jpg');
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-003: shows close button', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Close button exists (the X button in the top bar)
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-004: previous/next navigation works', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
// Initially shows photo 1
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', '/photos/1.jpg');
|
||||
|
||||
// Navigate to next photo via ArrowRight key
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/2.jpg');
|
||||
|
||||
// Navigate back via ArrowLeft key
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' });
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/1.jpg');
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-005: keyboard Escape closes lightbox', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-007: does not render when photos array is empty', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(<PhotoLightbox photos={[]} onClose={onClose} />);
|
||||
// Component returns null when photo is undefined (empty array, index 0 is undefined)
|
||||
expect(container.querySelector('img')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-008: calls onClose when close button clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
// The close button is in the top bar — find the button and click it
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// The first button in the top bar is the close (X) button
|
||||
buttons[0].click();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
|
||||
interface LightboxPhoto {
|
||||
id: string
|
||||
src: string
|
||||
caption?: string | null
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
photos: LightboxPhoto[]
|
||||
startIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
|
||||
const [idx, setIdx] = useState(startIndex)
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
const photo = photos[idx]
|
||||
const hasPrev = idx > 0
|
||||
const hasNext = idx < photos.length - 1
|
||||
|
||||
const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev])
|
||||
const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [prev, next, onClose])
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
const t = e.touches[0]
|
||||
touchStart.current = { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
if (!touchStart.current) return
|
||||
const t = e.changedTouches[0]
|
||||
const dx = t.clientX - touchStart.current.x
|
||||
const dy = t.clientY - touchStart.current.y
|
||||
|
||||
// swipe down to close
|
||||
if (dy > 80 && Math.abs(dx) < 60) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
// horizontal swipe
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||
if (dx < 0) next()
|
||||
else prev()
|
||||
}
|
||||
touchStart.current = null
|
||||
}
|
||||
|
||||
if (!photo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 500,
|
||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
paddingBottom: 'var(--bottom-nav-h)',
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Photo area — centered with nav overlays */}
|
||||
<div
|
||||
className="group/lightbox"
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
|
||||
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
|
||||
{idx + 1} / {photos.length}
|
||||
</span>
|
||||
<button onClick={onClose} style={{
|
||||
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
|
||||
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
|
||||
{hasPrev && (
|
||||
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
|
||||
position: 'absolute', left: 16, zIndex: 5,
|
||||
width: 44, height: 44, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Photo */}
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
style={{
|
||||
maxWidth: '92vw', maxHeight: '92vh',
|
||||
objectFit: 'contain', borderRadius: 4,
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Next button */}
|
||||
{hasNext && (
|
||||
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
|
||||
position: 'absolute', right: 16, zIndex: 5,
|
||||
width: 44, height: 44, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Caption — bottom center overlay */}
|
||||
{photo.caption && (
|
||||
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
|
||||
<p style={{
|
||||
fontSize: 14, fontStyle: 'italic',
|
||||
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
|
||||
padding: '6px 14px', borderRadius: 10,
|
||||
}}>{photo.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const DAY_COLORS = [
|
||||
'#6366f1', // indigo
|
||||
'#f97316', // orange
|
||||
'#14b8a6', // teal
|
||||
'#ec4899', // pink
|
||||
'#22c55e', // green
|
||||
'#3b82f6', // blue
|
||||
'#a855f7', // purple
|
||||
'#ef4444', // red
|
||||
'#f59e0b', // amber
|
||||
'#06b6d4', // cyan
|
||||
'#84cc16', // lime
|
||||
'#f43f5e', // rose
|
||||
'#8b5cf6', // violet
|
||||
'#10b981', // emerald
|
||||
'#fb923c', // orange-400
|
||||
'#60a5fa', // blue-400
|
||||
'#c084fc', // purple-400
|
||||
'#34d399', // emerald-400
|
||||
'#fbbf24', // amber-400
|
||||
'#e879f9', // fuchsia
|
||||
'#4ade80', // green-400
|
||||
'#f87171', // red-400
|
||||
'#38bdf8', // sky-400
|
||||
'#a3e635', // lime-400
|
||||
'#fb7185', // rose-400
|
||||
'#818cf8', // indigo-400
|
||||
'#2dd4bf', // teal-400
|
||||
'#facc15', // yellow
|
||||
'#c026d3', // fuchsia-600
|
||||
'#0ea5e9', // sky-500
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
// FE-COMP-MOOD-001 to FE-COMP-MOOD-005
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MOODS, WEATHERS, getMood, moodColor, tagColors, TAG_STYLES, MOOD_DEFAULT_COLOR } from './moodConfig';
|
||||
|
||||
describe('moodConfig', () => {
|
||||
it('FE-COMP-MOOD-001: MOODS contains all five mood definitions', () => {
|
||||
const ids = MOODS.map(m => m.id);
|
||||
expect(ids).toEqual(['amazing', 'good', 'neutral', 'tired', 'rough']);
|
||||
expect(MOODS).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-002: every mood has valid hex color and css var', () => {
|
||||
for (const mood of MOODS) {
|
||||
expect(mood.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
expect(mood.cssVar).toMatch(/^var\(--mood-.+\)$/);
|
||||
expect(mood.icon).toBeDefined();
|
||||
expect(mood.label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-003: getMood returns correct mood or undefined', () => {
|
||||
expect(getMood('amazing')?.id).toBe('amazing');
|
||||
expect(getMood('rough')?.color).toBe('#9B8EC4');
|
||||
expect(getMood('nonexistent')).toBeUndefined();
|
||||
expect(getMood(null)).toBeUndefined();
|
||||
expect(getMood(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-004: moodColor returns css var or fallback', () => {
|
||||
expect(moodColor('good')).toBe('var(--mood-good)');
|
||||
expect(moodColor(null)).toBe('var(--journal-faint)');
|
||||
expect(moodColor('unknown')).toBe('var(--journal-faint)');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-005: WEATHERS contains all eight entries with icons', () => {
|
||||
expect(WEATHERS).toHaveLength(8);
|
||||
const ids = WEATHERS.map(w => w.id);
|
||||
expect(ids).toContain('sunny');
|
||||
expect(ids).toContain('snowy');
|
||||
expect(ids).toContain('stormy');
|
||||
for (const w of WEATHERS) {
|
||||
expect(w.icon).toBeDefined();
|
||||
expect(w.label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagColors', () => {
|
||||
it('FE-COMP-MOOD-006: returns known tag colors for light and dark mode', () => {
|
||||
const light = tagColors('hidden gem', false);
|
||||
expect(light.bg).toBe('#dcfce7');
|
||||
expect(light.fg).toBe('#166534');
|
||||
|
||||
const dark = tagColors('hidden gem', true);
|
||||
expect(dark.bg).toBe('rgba(22,101,52,0.2)');
|
||||
expect(dark.fg).toBe('#86efac');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-007: returns fallback colors for unknown tags', () => {
|
||||
const light = tagColors('random tag', false);
|
||||
expect(light.bg).toBe('rgba(0,0,0,0.05)');
|
||||
expect(light.fg).toBe('#374151');
|
||||
|
||||
const dark = tagColors('random tag', true);
|
||||
expect(dark.bg).toBe('rgba(255,255,255,0.07)');
|
||||
expect(dark.fg).toBe('#a1a1aa');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Sparkles, Sun, Minus, Moon, CloudRain, CloudSun, Cloud, CloudLightning, Snowflake, Thermometer, ThermometerSnowflake } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface MoodDef {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
color: string
|
||||
cssVar: string
|
||||
}
|
||||
|
||||
export const MOODS: MoodDef[] = [
|
||||
{ id: 'amazing', label: 'Amazing', icon: Sparkles, color: '#E8654A', cssVar: 'var(--mood-amazing)' },
|
||||
{ id: 'good', label: 'Good', icon: Sun, color: '#EF9F27', cssVar: 'var(--mood-good)' },
|
||||
{ id: 'neutral', label: 'Neutral', icon: Minus, color: '#94928C', cssVar: 'var(--mood-neutral)' },
|
||||
{ id: 'tired', label: 'Tired', icon: Moon, color: '#6B9BD2', cssVar: 'var(--mood-tired)' },
|
||||
{ id: 'rough', label: 'Rough', icon: CloudRain,color: '#9B8EC4', cssVar: 'var(--mood-rough)' },
|
||||
]
|
||||
|
||||
export const MOOD_DEFAULT_COLOR = '#D4D4D4'
|
||||
|
||||
export function getMood(id: string | null | undefined): MoodDef | undefined {
|
||||
if (!id) return undefined
|
||||
return MOODS.find(m => m.id === id)
|
||||
}
|
||||
|
||||
export function moodColor(id: string | null | undefined): string {
|
||||
return getMood(id)?.cssVar || 'var(--journal-faint)'
|
||||
}
|
||||
|
||||
export interface WeatherDef {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export const WEATHERS: WeatherDef[] = [
|
||||
{ id: 'sunny', label: 'Sunny', icon: Sun },
|
||||
{ id: 'partly', label: 'Partly cloudy', icon: CloudSun },
|
||||
{ id: 'cloudy', label: 'Cloudy', icon: Cloud },
|
||||
{ id: 'rainy', label: 'Rainy', icon: CloudRain },
|
||||
{ id: 'stormy', label: 'Stormy', icon: CloudLightning },
|
||||
{ id: 'snowy', label: 'Snowy', icon: Snowflake },
|
||||
{ id: 'hot', label: 'Hot', icon: Thermometer },
|
||||
{ id: 'cold', label: 'Cold', icon: ThermometerSnowflake },
|
||||
]
|
||||
|
||||
export function getWeather(id: string | null | undefined): WeatherDef | undefined {
|
||||
if (!id) return undefined
|
||||
return WEATHERS.find(w => w.id === id)
|
||||
}
|
||||
|
||||
export const TAG_STYLES: Record<string, { bg: string; fg: string; darkBg: string; darkFg: string }> = {
|
||||
'hidden gem': { bg: '#dcfce7', fg: '#166534', darkBg: 'rgba(22,101,52,0.2)', darkFg: '#86efac' },
|
||||
'must revisit': { bg: '#dbeafe', fg: '#1e40af', darkBg: 'rgba(30,64,175,0.2)', darkFg: '#93c5fd' },
|
||||
'best meal': { bg: '#fef3c7', fg: '#92400e', darkBg: 'rgba(146,64,14,0.2)', darkFg: '#fcd34d' },
|
||||
'tourist trap': { bg: '#fee2e2', fg: '#991b1b', darkBg: 'rgba(153,27,27,0.2)', darkFg: '#fca5a5' },
|
||||
'disaster': { bg: '#fce4ec', fg: '#880e4f', darkBg: 'rgba(136,14,79,0.2)', darkFg: '#f48fb1' },
|
||||
}
|
||||
|
||||
export function tagColors(tag: string, dark: boolean) {
|
||||
const known = TAG_STYLES[tag.toLowerCase()]
|
||||
if (known) return { bg: dark ? known.darkBg : known.bg, fg: dark ? known.darkFg : known.fg }
|
||||
return { bg: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)', fg: dark ? '#a1a1aa' : '#374151' }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// FE-UTIL-STRIPMD-001 to FE-UTIL-STRIPMD-006
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stripMarkdown } from './stripMarkdown';
|
||||
|
||||
describe('stripMarkdown', () => {
|
||||
it('FE-UTIL-STRIPMD-001: strips bold and italic formatting', () => {
|
||||
expect(stripMarkdown('**bold** and _italic_')).toBe('bold and italic');
|
||||
expect(stripMarkdown('__also bold__ and *also italic*')).toBe('also bold and also italic');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-002: strips headings', () => {
|
||||
expect(stripMarkdown('# Heading 1')).toBe('Heading 1');
|
||||
expect(stripMarkdown('## Heading 2')).toBe('Heading 2');
|
||||
expect(stripMarkdown('### Heading 3')).toBe('Heading 3');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-003: converts links to text and removes images', () => {
|
||||
expect(stripMarkdown('[click here](https://example.com)')).toBe('click here');
|
||||
expect(stripMarkdown('')).toBe('');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-004: strips code blocks and inline code', () => {
|
||||
expect(stripMarkdown('use `console.log`')).toBe('use console.log');
|
||||
expect(stripMarkdown('```\ncode block\n```')).toBe('');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-005: strips blockquotes and lists', () => {
|
||||
expect(stripMarkdown('> quoted text')).toBe('quoted text');
|
||||
expect(stripMarkdown('- item one')).toBe('item one');
|
||||
expect(stripMarkdown('1. first item')).toBe('first item');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-006: strips strikethrough and horizontal rules', () => {
|
||||
expect(stripMarkdown('~~deleted~~')).toBe('deleted');
|
||||
expect(stripMarkdown('---')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Strip markdown formatting to get plain text for previews.
|
||||
* Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr.
|
||||
*/
|
||||
export function stripMarkdown(md: string): string {
|
||||
return md
|
||||
.replace(/^#{1,6}\s+/gm, '') // headings
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // images
|
||||
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
|
||||
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
|
||||
.replace(/`([^`]+)`/g, '$1') // inline code
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1') // bold **
|
||||
.replace(/__(.+?)__/g, '$1') // bold __
|
||||
.replace(/\*(.+?)\*/g, '$1') // italic *
|
||||
.replace(/_(.+?)_/g, '$1') // italic _
|
||||
.replace(/~~(.+?)~~/g, '$1') // strikethrough
|
||||
.replace(/^>\s?/gm, '') // blockquotes
|
||||
.replace(/^[-*+]\s+/gm, '') // unordered lists
|
||||
.replace(/^\d+\.\s+/gm, '') // ordered lists
|
||||
.replace(/^---+$/gm, '') // horizontal rules
|
||||
.replace(/\n{2,}/g, ' ') // collapse multiple newlines
|
||||
.replace(/\n/g, ' ') // remaining newlines → spaces
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import BottomNav from './BottomNav';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
mockNavigate.mockClear();
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
});
|
||||
|
||||
describe('BottomNav', () => {
|
||||
it('FE-COMP-BOTTOMNAV-001: renders without crashing', () => {
|
||||
render(<BottomNav />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
// Profile sheet shows username
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => {
|
||||
const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' });
|
||||
seedStore(useAuthStore, { user: adminUser, isAuthenticated: true });
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
// Sheet is open — username visible
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
// The outermost fixed div is the backdrop wrapper, clicking it triggers onClose
|
||||
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.click(backdrop);
|
||||
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
|
||||
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
|
||||
{ to: '/trips', label: 'Trips', icon: Plane },
|
||||
]
|
||||
|
||||
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
|
||||
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
|
||||
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
|
||||
journey: { to: '/journey', label: 'Journey', icon: Compass },
|
||||
}
|
||||
|
||||
export default function BottomNav() {
|
||||
const { t } = useTranslation()
|
||||
const darkMode = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const addons = useAddonStore(s => s.addons)
|
||||
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||
const [showProfile, setShowProfile] = useState(false)
|
||||
|
||||
const items = [...BASE_ITEMS]
|
||||
for (const addon of globalAddons) {
|
||||
const nav = ADDON_NAV[addon.id]
|
||||
if (nav) items.push(nav)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
|
||||
style={{
|
||||
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
background: dark ? 'rgba(9,9,11,0.96)' : 'rgba(255,255,255,0.96)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
|
||||
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowProfile(true)}
|
||||
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
|
||||
>
|
||||
<User size={22} strokeWidth={2} />
|
||||
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileSheet({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleNav = (path: string) => {
|
||||
onClose()
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose()
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
|
||||
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Handle */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
<div className="px-6 pb-4 pt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
|
||||
{(user?.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
|
||||
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
{user?.role === 'admin' && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
|
||||
<Shield size={10} /> Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
||||
|
||||
{/* Links */}
|
||||
<div className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => handleNav('/settings')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<Settings size={18} className="text-zinc-500" />
|
||||
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
|
||||
</button>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => handleNav('/admin')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<Shield size={18} className="text-zinc-500" />
|
||||
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
||||
|
||||
{/* Logout */}
|
||||
<div className="py-2 px-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
|
||||
>
|
||||
<LogOut size={18} className="text-red-500" />
|
||||
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoBanner from './DemoBanner';
|
||||
|
||||
describe('DemoBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-001
|
||||
it('renders without crashing', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-002
|
||||
it('overlay is visible on initial render with dismiss button', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-003
|
||||
it('shows English welcome title by default', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-004
|
||||
it('clicking "Got it" dismisses the banner', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoBanner />);
|
||||
const button = screen.getByText('Got it');
|
||||
await user.click(button);
|
||||
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-005
|
||||
it('clicking the overlay backdrop dismisses the banner', () => {
|
||||
const { container } = render(<DemoBanner />);
|
||||
// The outermost fixed div is the overlay backdrop
|
||||
const overlay = container.firstChild as HTMLElement;
|
||||
fireEvent.click(overlay);
|
||||
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-006
|
||||
it('clicking the inner card does NOT dismiss', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoBanner />);
|
||||
// The inner card is the direct parent of the "Got it" button's container
|
||||
const card = screen.getByText('Got it').closest('div[style*="background: white"]')!;
|
||||
await user.click(card);
|
||||
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-007
|
||||
it('shows reset timer', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Next reset in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-008
|
||||
it('shows upload-disabled notice', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/File uploads.*disabled in demo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-009
|
||||
it('shows "What is TREK?" section', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('What is TREK?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-010
|
||||
it('shows addon cards', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.getByText('Atlas')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-011
|
||||
it('shows full version features section', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Additionally in the full version/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-012
|
||||
it('self-host link points to GitHub', () => {
|
||||
render(<DemoBanner />);
|
||||
const link = screen.getByText('self-host it').closest('a')!;
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/mauriceboe/TREK');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
// Timer update test
|
||||
it('updates countdown timer after interval tick', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: false });
|
||||
// Set time to XX:30 so minutesLeft = 59 - 30 = 29
|
||||
vi.setSystemTime(new Date(2026, 3, 7, 12, 30, 0));
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/29 minutes/)).toBeInTheDocument();
|
||||
|
||||
// Advance to XX:31 and tick the interval; wrap in act so React flushes state update
|
||||
await act(async () => {
|
||||
vi.setSystemTime(new Date(2026, 3, 7, 12, 31, 0));
|
||||
vi.advanceTimersByTime(10000);
|
||||
});
|
||||
expect(screen.getByText(/28 minutes/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -214,6 +214,38 @@ const texts: Record<string, DemoTexts> = {
|
||||
selfHostLink: 'استضفه بنفسك',
|
||||
close: 'فهمت',
|
||||
},
|
||||
id: {
|
||||
titleBefore: 'Selamat datang di ',
|
||||
titleAfter: '',
|
||||
title: 'Selamat datang di Demo TREK',
|
||||
description: 'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
|
||||
resetIn: 'Atur ulang berikutnya dalam',
|
||||
minutes: 'menit',
|
||||
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
|
||||
fullVersionTitle: 'Selain itu dalam versi lengkap:',
|
||||
features: [
|
||||
'Unggah file (foto, dokumen, sampul)',
|
||||
'Manajemen kunci API (Google Maps, Cuaca)',
|
||||
'Manajemen pengguna & izin',
|
||||
'Pencadangan otomatis',
|
||||
'Manajemen Addon (aktifkan/nonaktifkan)',
|
||||
'OIDC / SSO single sign-on',
|
||||
],
|
||||
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
|
||||
addons: [
|
||||
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
|
||||
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
|
||||
['Pengepakan', 'Daftar periksa per perjalanan'],
|
||||
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
|
||||
['Dokumen', 'Lampirkan file ke perjalanan'],
|
||||
['Widget', 'Konverter mata uang & zona waktu'],
|
||||
],
|
||||
whatIs: 'Apa itu TREK?',
|
||||
whatIsDesc: 'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
|
||||
selfHost: 'Buka sumber — ',
|
||||
selfHostLink: 'host mandiri',
|
||||
close: 'Mengerti',
|
||||
},
|
||||
}
|
||||
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||
@@ -234,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 16, overflow: 'auto',
|
||||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
overflow: 'auto',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}} onClick={() => setDismissed(true)}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||
maxWidth: 480, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: '90vh', overflow: 'auto',
|
||||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||
overflow: 'auto',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
@@ -335,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
||||
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
position: 'sticky', bottom: 0, background: 'white',
|
||||
marginTop: 'auto',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||
<Github size={13} />
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// FE-COMP-BELL-001 to FE-COMP-BELL-020
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import InAppNotificationBell from './InAppNotificationBell';
|
||||
|
||||
let _notifId = 1;
|
||||
function buildNotification(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: _notifId++,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: 1,
|
||||
sender_id: 2,
|
||||
sender_username: 'alice',
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key: 'test',
|
||||
title_params: '{}',
|
||||
text_key: 'test.text',
|
||||
text_params: '{}',
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: 0,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
_notifId = 1;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
});
|
||||
|
||||
describe('InAppNotificationBell', () => {
|
||||
it('FE-COMP-BELL-001: renders without crashing', () => {
|
||||
render(<InAppNotificationBell />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-002: shows bell button', () => {
|
||||
render(<InAppNotificationBell />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-003: clicking bell opens notification panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InAppNotificationBell />);
|
||||
const bell = screen.getAllByRole('button')[0];
|
||||
await user.click(bell);
|
||||
// Panel shows "Notifications" title
|
||||
await screen.findByText('Notifications');
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-004: notification panel shows empty state when no notifications', async () => {
|
||||
const { http, HttpResponse } = await import('msw');
|
||||
const { server } = await import('../../../tests/helpers/msw/server');
|
||||
server.use(
|
||||
http.get('/api/notifications/in-app', () => HttpResponse.json({ notifications: [], total: 0, unread_count: 0 })),
|
||||
http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 0 })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<InAppNotificationBell />);
|
||||
const bell = screen.getAllByRole('button')[0];
|
||||
await user.click(bell);
|
||||
await screen.findByText('No notifications');
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-005: shows unread badge count when there are unread notifications', async () => {
|
||||
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 5, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-006: does not show badge when unread count is 0', () => {
|
||||
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-007: panel shows Mark all read button when panel is open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const notification = {
|
||||
id: 1, type: 'simple', scope: 'trip', target: 1, sender_id: 2,
|
||||
sender_username: 'alice', sender_avatar: null, recipient_id: 1,
|
||||
title_key: 'test', title_params: '{}', text_key: 'test.text', text_params: '{}',
|
||||
positive_text_key: null, negative_text_key: null, response: null,
|
||||
navigate_text_key: null, navigate_target: null, is_read: 0,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
};
|
||||
seedStore(useInAppNotificationStore, { notifications: [notification], unreadCount: 1, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
const bell = screen.getAllByRole('button')[0];
|
||||
await user.click(bell);
|
||||
await screen.findByTitle('Mark all read');
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-008: panel shows empty description when no notifications', async () => {
|
||||
const { http, HttpResponse } = await import('msw');
|
||||
const { server } = await import('../../../tests/helpers/msw/server');
|
||||
server.use(
|
||||
http.get('/api/notifications/in-app', () => HttpResponse.json({ notifications: [], total: 0, unread_count: 0 })),
|
||||
http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 0 })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await screen.findByText("You're all caught up!");
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-009: bell is accessible as a button', () => {
|
||||
render(<InAppNotificationBell />);
|
||||
const bell = screen.getAllByRole('button')[0];
|
||||
expect(bell).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-010: unread count greater than 99 shows 99+', () => {
|
||||
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 150, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
// Should show "99+" not "150"
|
||||
expect(screen.queryByText('150')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('99+')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-011: Delete all button shown when notifications exist', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useInAppNotificationStore, { notifications: [buildNotification()], unreadCount: 1, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
expect(screen.getByTitle('Delete all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-012: Delete all button NOT shown when no notifications', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn() });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await screen.findByText('Notifications');
|
||||
expect(screen.queryByTitle('Delete all')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-013: Mark all read button NOT shown when unreadCount is 0', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: 1 })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await screen.findByText('Notifications');
|
||||
expect(screen.queryByTitle('Mark all read')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-014: clicking Mark all read calls store action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const markAllRead = vi.fn();
|
||||
seedStore(useInAppNotificationStore, { notifications: [buildNotification()], unreadCount: 1, isLoading: false, markAllRead });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await user.click(screen.getByTitle('Mark all read'));
|
||||
expect(markAllRead).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-015: clicking Delete all calls store action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const deleteAll = vi.fn();
|
||||
seedStore(useInAppNotificationStore, { notifications: [buildNotification()], unreadCount: 1, isLoading: false, deleteAll });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await user.click(screen.getByTitle('Delete all'));
|
||||
expect(deleteAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-016: Show all notifications navigates to /notifications', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await screen.findByText('Notifications');
|
||||
const showAllBtn = screen.getByText('Show all notifications');
|
||||
await user.click(showAllBtn);
|
||||
// Panel should close after clicking show all
|
||||
expect(screen.queryByText('No notifications')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-017: loading spinner shown when isLoading=true and notifications empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: true, fetchNotifications: vi.fn() });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-018: notification items rendered up to 10', async () => {
|
||||
const user = userEvent.setup();
|
||||
const notifications = Array.from({ length: 12 }, (_, i) => buildNotification({ id: i + 1 }));
|
||||
seedStore(useInAppNotificationStore, { notifications, unreadCount: 12, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await screen.findByText('Notifications');
|
||||
// Each InAppNotificationItem renders with py-3 px-4 pattern; count rendered items
|
||||
const items = document.querySelectorAll('.relative.px-4.py-3');
|
||||
expect(items.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-019: clicking outside the panel closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await screen.findByText('Notifications');
|
||||
// The backdrop div is the fixed overlay — click it to close
|
||||
const backdrop = document.querySelector('div[style*="position: fixed"][style*="inset: 0"]') as HTMLElement;
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
await user.click(backdrop);
|
||||
// Panel should be gone — "No notifications" text no longer visible
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('No notifications')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-BELL-020: panel does not fetch again when already open and clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchNotifications = vi.fn();
|
||||
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false, fetchNotifications });
|
||||
render(<InAppNotificationBell />);
|
||||
const bell = screen.getAllByRole('button')[0];
|
||||
// Open
|
||||
await user.click(bell);
|
||||
// Close
|
||||
await user.click(bell);
|
||||
// Re-open
|
||||
await user.click(bell);
|
||||
// fetchNotifications should be called once per open (2 total)
|
||||
expect(fetchNotifications).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import MobileTopHeader from './MobileTopHeader';
|
||||
|
||||
describe('MobileTopHeader', () => {
|
||||
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
|
||||
render(<MobileTopHeader title="Journeys" />);
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading.textContent).toBe('Journeys');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
|
||||
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
|
||||
expect(screen.getByText('3 trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
|
||||
const { container } = render(<MobileTopHeader title="Journeys" />);
|
||||
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
|
||||
expect(subtitleEl).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
|
||||
render(
|
||||
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
|
||||
return (
|
||||
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
|
||||
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// FE-COMP-NAVBAR-001 to FE-COMP-NAVBAR-028
|
||||
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 { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
});
|
||||
|
||||
describe('Navbar', () => {
|
||||
it('FE-COMP-NAVBAR-001: renders without crashing', () => {
|
||||
render(<Navbar />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-002: shows TREK logo/brand', () => {
|
||||
render(<Navbar />);
|
||||
// The Navbar shows the app icon — check for presence of the nav element
|
||||
expect(document.querySelector('nav') || document.body).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-003: shows username in user menu trigger', () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-004: user menu opens on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
// Click the username to open dropdown
|
||||
await user.click(screen.getByText('testuser'));
|
||||
// Settings option appears
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-005: user menu shows Log out option', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
expect(screen.getByText('Log out')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-006: shows Settings link in user menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-007: shows My Trips link in navbar', () => {
|
||||
render(<Navbar />);
|
||||
// nav.myTrips = "My Trips" is in the main navbar (hidden on mobile via CSS, but CSS is not processed in tests)
|
||||
// The link to /dashboard is present regardless
|
||||
const dashboardLinks = document.querySelectorAll('a[href="/dashboard"]');
|
||||
expect(dashboardLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-008: clicking Log out calls logout', async () => {
|
||||
const user = userEvent.setup();
|
||||
const logout = vi.fn();
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, logout });
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
await user.click(screen.getByText('Log out'));
|
||||
expect(logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-009: admin user sees Admin option', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'admin', role: 'admin' }), isAuthenticated: true });
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('admin'));
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-011: shows tripTitle when provided', () => {
|
||||
render(<Navbar tripTitle="Paris 2026" />);
|
||||
expect(screen.getByText('Paris 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-012: shows back button when showBack is true', () => {
|
||||
render(<Navbar showBack={true} onBack={vi.fn()} />);
|
||||
// Back button is a button element
|
||||
const backBtns = screen.getAllByRole('button');
|
||||
expect(backBtns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-013: clicking back button calls onBack', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
render(<Navbar showBack={true} onBack={onBack} />);
|
||||
// Find the back button (ArrowLeft icon)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// First button should be the back button
|
||||
await user.click(buttons[0]);
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-014: notification bell is rendered when user is logged in', () => {
|
||||
render(<Navbar />);
|
||||
// InAppNotificationBell is rendered — check that body has some content
|
||||
expect(document.body.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-015: dark mode toggle is accessible in user menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
// Dark mode / Light mode / Auto mode options
|
||||
const darkModeEls = screen.getAllByRole('button');
|
||||
expect(darkModeEls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-016: app version shown in user menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v2.9.10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-017: Settings link navigates to /settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
const settingsLink = screen.getByRole('link', { name: /settings/i });
|
||||
expect(settingsLink).toHaveAttribute('href', '/settings');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-018: Admin link navigates to /admin for admin user', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'adminuser', role: 'admin' }), isAuthenticated: true });
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('adminuser'));
|
||||
const adminLink = screen.getByRole('link', { name: /admin/i });
|
||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
|
||||
render(<Navbar onShare={vi.fn()} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
expect(shareBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onShare = vi.fn();
|
||||
render(<Navbar onShare={onShare} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareBtn);
|
||||
expect(onShare).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-022: dark mode toggle shows Moon when light, Sun when dark', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
|
||||
const { unmount } = render(<Navbar />);
|
||||
// Moon icon button should be present (title = 'nav.darkMode' i.e. 'Dark mode')
|
||||
expect(document.querySelector('[title]')).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
||||
render(<Navbar />);
|
||||
// Sun icon button should be present when dark mode is on
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-023: dark mode toggle calls updateSetting', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
||||
render(<Navbar />);
|
||||
// Find the dark mode toggle button by title attribute
|
||||
const toggleBtn = document.querySelector('button[title]') as HTMLElement;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await user.click(toggleBtn);
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-024: global addon nav links appear when addons enabled', () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({
|
||||
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
|
||||
})),
|
||||
);
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
|
||||
});
|
||||
render(<Navbar />);
|
||||
expect(screen.getByRole('link', { name: /vacay/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-025: global addon links hidden when in trip view (tripTitle set)', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
|
||||
});
|
||||
render(<Navbar tripTitle="Japan 2025" />);
|
||||
expect(screen.queryByRole('link', { name: /vacay/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-026: notification bell visible when tripId provided', () => {
|
||||
render(<Navbar tripId="1" />);
|
||||
// InAppNotificationBell renders a button — check it is present
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-027: user avatar image shown when avatar_url set', () => {
|
||||
seedStore(useAuthStore, {
|
||||
user: buildUser({ username: 'testuser', avatar_url: 'https://example.com/av.jpg' }),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
render(<Navbar />);
|
||||
const avatarImg = document.querySelector('img[src="https://example.com/av.jpg"]');
|
||||
expect(avatarImg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-028: user initial shown when no avatar_url', () => {
|
||||
seedStore(useAuthStore, {
|
||||
user: buildUser({ username: 'testuser', avatar_url: null }),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
render(<Navbar />);
|
||||
// The initial is rendered as the first char uppercased in a div
|
||||
expect(screen.getAllByText('T')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-029: clicking backdrop overlay closes user menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
// The backdrop overlay is a fixed-inset div rendered in the portal
|
||||
const backdrop = document.querySelector('[style*="inset: 0"]') as HTMLElement;
|
||||
if (backdrop) {
|
||||
await user.click(backdrop);
|
||||
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-030: dark mode auto uses system preference', () => {
|
||||
// 'auto' dark_mode relies on matchMedia — seed with auto and render
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'auto' }) });
|
||||
render(<Navbar />);
|
||||
// Component should render without errors regardless of system preference
|
||||
expect(document.querySelector('nav')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-031: dark mode toggle calls updateSetting with light when currently dark', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }), updateSetting });
|
||||
render(<Navbar />);
|
||||
const toggleBtn = document.querySelector('button[title]') as HTMLElement;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await user.click(toggleBtn);
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'light');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-032: user email shown in open user menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, {
|
||||
user: buildUser({ username: 'testuser', email: 'testuser@example.com' }),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('testuser'));
|
||||
expect(screen.getByText('testuser@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-033: administrator badge shown for admin user in open menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, {
|
||||
user: buildUser({ username: 'adminuser', role: 'admin' }),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
render(<Navbar />);
|
||||
await user.click(screen.getByText('adminuser'));
|
||||
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
|
||||
|
||||
interface NavbarProps {
|
||||
tripTitle?: string
|
||||
@@ -27,17 +27,28 @@ interface Addon {
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [scrolled, setScrolled] = useState<boolean>(false)
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
document.body.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
document.body.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||
|
||||
@@ -45,19 +56,31 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
if (user) loadAddons()
|
||||
}, [user, location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
import('../../api/client').then(({ authApi }) => {
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
}
|
||||
|
||||
// Keep track of the pending theme-transition cleanup so we can cancel it
|
||||
// on unmount. Without this the timer fires after jsdom teardown in unit
|
||||
// tests (document is gone) and triggers an unhandled ReferenceError that
|
||||
// trips vitest's exit code.
|
||||
const themeTransitionTimer = useRef<number | null>(null)
|
||||
useEffect(() => () => {
|
||||
if (themeTransitionTimer.current !== null) {
|
||||
window.clearTimeout(themeTransitionTimer.current)
|
||||
themeTransitionTimer.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
document.documentElement.classList.add('trek-theme-transitioning')
|
||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
|
||||
themeTransitionTimer.current = window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||
themeTransitionTimer.current = null
|
||||
}, 360)
|
||||
}
|
||||
|
||||
const getAddonName = (addon: Addon): string => {
|
||||
@@ -68,23 +91,29 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
background: dark
|
||||
? (scrolled ? 'rgba(9,9,11,0.78)' : 'rgba(9,9,11,0.95)')
|
||||
: (scrolled ? 'rgba(255,255,255,0.72)' : 'rgba(255,255,255,0.95)'),
|
||||
backdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
|
||||
WebkitBackdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
|
||||
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
||||
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
||||
boxShadow: scrolled
|
||||
? (dark ? '0 4px 24px rgba(0,0,0,0.35)' : '0 4px 24px rgba(0,0,0,0.08)')
|
||||
: (dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)'),
|
||||
touchAction: 'manipulation',
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
height: 'var(--nav-h)',
|
||||
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{showBack && (
|
||||
<button onClick={onBack}
|
||||
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="trek-back-icon w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.back')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -155,13 +184,27 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Prerelease badge */}
|
||||
{isPrerelease && appVersion && (
|
||||
<span
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
|
||||
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
|
||||
{appVersion}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
<Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ opacity: dark ? 1 : 0, transform: dark ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0.6)' }} />
|
||||
<Moon className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ opacity: dark ? 0 : 1, transform: dark ? 'rotate(90deg) scale(0.6)' : 'rotate(0deg) scale(1)' }} />
|
||||
</button>
|
||||
|
||||
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
||||
@@ -192,7 +235,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{userMenuOpen && ReactDOM.createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||
@@ -238,7 +281,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
<a href="https://discord.gg/NhZBDSd4qW" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* OfflineBanner — connectivity + sync state indicator.
|
||||
*
|
||||
* States:
|
||||
* offline + N queued → amber pill "Offline · N queued"
|
||||
* offline + 0 queued → amber pill "Offline"
|
||||
* online + N pending → blue pill "Syncing N…"
|
||||
* online + 0 pending → hidden
|
||||
*
|
||||
* Rendered as a small floating pill anchored to the bottom-center of the
|
||||
* viewport so it never competes with top navigation or sticky modal
|
||||
* headers. On mobile it hovers just above the bottom tab bar.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 3_000
|
||||
|
||||
export default function OfflineBanner(): React.ReactElement | null {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => setIsOnline(true)
|
||||
const onOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function poll() {
|
||||
const n = await mutationQueue.pendingCount()
|
||||
if (!cancelled) setPendingCount(n)
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, POLL_MS)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const hidden = isOnline && pendingCount === 0
|
||||
if (hidden) return null
|
||||
|
||||
const offline = !isOnline
|
||||
const bg = offline ? '#92400e' : '#1e40af'
|
||||
const text = '#fff'
|
||||
|
||||
const label = offline
|
||||
? pendingCount > 0
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount}…`
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
|
||||
// so the pill sits 16px from the bottom.
|
||||
bottom: 'calc(var(--bottom-nav-h) + 16px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
background: bg,
|
||||
color: text,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
borderRadius: 999,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{offline
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Menu, X, type LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface PageSidebarTab {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
interface PageSidebarProps {
|
||||
/** Uppercase label shown above the tab list, e.g. "SETTINGS". */
|
||||
sidebarLabel: string
|
||||
tabs: PageSidebarTab[]
|
||||
activeTab: string
|
||||
onTabChange: (id: string) => void
|
||||
children: React.ReactNode
|
||||
/** Small text at the very bottom of the sidebar (e.g. "v3.0 · self-hosted"). */
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-sidebar + right-panel layout used by the Settings and Admin pages.
|
||||
*
|
||||
* Desktop (>=1024px): sidebar is always visible at 260px; panel fills rest.
|
||||
* Mobile: sidebar collapses behind a hamburger at the top of the panel; tap
|
||||
* the hamburger to slide the sidebar in as an overlay, tap a tab to close.
|
||||
*/
|
||||
export default function PageSidebar({
|
||||
sidebarLabel,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
children,
|
||||
footer,
|
||||
}: PageSidebarProps): React.ReactElement {
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const activeLabel = tabs.find(t => t.id === activeTab)?.label ?? ''
|
||||
|
||||
// Close the mobile drawer on Escape or on outside click.
|
||||
const drawerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMobileOpen(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [mobileOpen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
|
||||
}}
|
||||
>
|
||||
{/* Mobile top bar with hamburger */}
|
||||
<div
|
||||
className="lg:hidden flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||
aria-label="Open navigation"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{activeLabel}
|
||||
</div>
|
||||
<div className="w-9" />
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar (always visible on lg) */}
|
||||
<aside
|
||||
className="hidden lg:flex flex-col shrink-0 relative"
|
||||
style={{
|
||||
width: 260,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRight: '1px solid var(--border-primary)',
|
||||
padding: '24px 14px',
|
||||
}}
|
||||
>
|
||||
<SidebarInner
|
||||
sidebarLabel={sidebarLabel}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
footer={footer}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<>
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40"
|
||||
style={{ background: 'rgba(0,0,0,0.35)' }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl"
|
||||
style={{
|
||||
width: 280,
|
||||
background: 'var(--bg-secondary)',
|
||||
padding: '18px 14px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 px-2">
|
||||
<span
|
||||
className="text-[11px] font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{sidebarLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||
aria-label="Close navigation"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarInner
|
||||
sidebarLabel={null}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => {
|
||||
onTabChange(id)
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
footer={footer}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div className="flex-1 min-w-0" style={{ padding: '26px 28px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInner({
|
||||
sidebarLabel,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
footer,
|
||||
}: {
|
||||
sidebarLabel: string | null
|
||||
tabs: PageSidebarTab[]
|
||||
activeTab: string
|
||||
onTabChange: (id: string) => void
|
||||
footer?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{sidebarLabel && (
|
||||
<div
|
||||
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{sidebarLabel}
|
||||
</div>
|
||||
)}
|
||||
<nav className="flex flex-col gap-1 flex-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = tab.id === activeTab
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
||||
style={{
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: active ? 600 : 500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" />
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{footer && (
|
||||
<div
|
||||
className="mt-4 pt-3 px-3 text-[10px] tracking-wide"
|
||||
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||
import type { TrackingMode } from '../../hooks/useGeolocation'
|
||||
|
||||
interface Props {
|
||||
mode: TrackingMode
|
||||
error: string | null
|
||||
onClick: () => void
|
||||
// Offset from the bottom edge — callers push this up above the mobile
|
||||
// bottom nav. Defaults to 20px for desktop.
|
||||
bottomOffset?: number
|
||||
}
|
||||
|
||||
// Three-state FAB. Matches the Apple/Google Maps pattern:
|
||||
// off → outline locate icon
|
||||
// show → filled locate (blue dot is visible on the map)
|
||||
// follow → filled navigation arrow (map follows + rotates with heading)
|
||||
export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) {
|
||||
const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate
|
||||
const isActive = mode !== 'off'
|
||||
const title = error
|
||||
? error
|
||||
: mode === 'off'
|
||||
? 'Show my location'
|
||||
: mode === 'show'
|
||||
? 'Follow my location'
|
||||
: 'Stop following'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: bottomOffset,
|
||||
right: 12,
|
||||
zIndex: 1000,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={mode === 'follow' ? 2.5 : 2} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import * as photoService from '../../services/photoService'
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
|
||||
TileLayer: () => <div data-testid="tile-layer" />,
|
||||
Marker: ({ children, eventHandlers, position }: any) => (
|
||||
<div
|
||||
data-testid="marker"
|
||||
data-lat={position[0]}
|
||||
data-lng={position[1]}
|
||||
onClick={() => eventHandlers?.click?.()}
|
||||
>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
useMap: () => ({
|
||||
panTo: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
getZoom: () => 10,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
}),
|
||||
useMapEvents: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('react-leaflet-cluster', () => ({
|
||||
default: ({ children }: any) => <div data-testid="cluster-group">{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('leaflet', () => ({
|
||||
default: {
|
||||
divIcon: vi.fn(() => ({})),
|
||||
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
|
||||
latLngBounds: vi.fn(() => ({ isValid: () => true })),
|
||||
point: vi.fn((x: number, y: number) => [x, y]),
|
||||
},
|
||||
divIcon: vi.fn(() => ({})),
|
||||
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
|
||||
latLngBounds: vi.fn(() => ({ isValid: () => true })),
|
||||
point: vi.fn((x: number, y: number) => [x, y]),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
import { MapView } from './MapView'
|
||||
|
||||
// Helper: build a place with the extra fields MapView uses (category_name/color/icon)
|
||||
// that exist on joined DB rows but are not in the base Place TypeScript type.
|
||||
function buildMapPlace(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
...buildPlace(),
|
||||
category_name: null,
|
||||
category_color: null,
|
||||
category_icon: null,
|
||||
...overrides,
|
||||
} as any
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
describe('MapView', () => {
|
||||
it('FE-COMP-MAPVIEW-001: renders map container', () => {
|
||||
render(<MapView />)
|
||||
expect(screen.getByTestId('map-container')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-002: renders one marker per place', () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
buildMapPlace({ id: 2, name: 'Louvre', lat: 48.86, lng: 2.337 }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
expect(screen.getAllByTestId('marker').length).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-003: marker click calls onMarkerClick with place id', () => {
|
||||
const onMarkerClick = vi.fn()
|
||||
const places = [buildMapPlace({ id: 42, lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} onMarkerClick={onMarkerClick} />)
|
||||
fireEvent.click(screen.getByTestId('marker'))
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||
render(<MapView route={null} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
|
||||
render(<MapView route={[[[48.0, 2.0]]]} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-009: GPX geometry polyline rendered for place with route_geometry', () => {
|
||||
const places = [
|
||||
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]' }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => {
|
||||
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} />)
|
||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
||||
const routeSegments = [
|
||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
||||
]
|
||||
render(<MapView route={route} routeSegments={routeSegments} />)
|
||||
// Route polyline is rendered
|
||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
||||
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
|
||||
// so we just assert the polyline is there, exercising the routeSegments.map path
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
||||
const places = [
|
||||
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: 'NOT_VALID_JSON' }),
|
||||
]
|
||||
// Should not throw; invalid JSON is caught silently
|
||||
render(<MapView places={places} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-013: route_geometry with fewer than 2 coords skips polyline', () => {
|
||||
const places = [
|
||||
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0]]' }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-014: marker icon uses base64 image_url for photo places', () => {
|
||||
const dataUrl = 'data:image/jpeg;base64,/9j/4AA'
|
||||
const places = [buildMapPlace({ id: 10, lat: 48.0, lng: 2.0, image_url: dataUrl })]
|
||||
render(<MapView places={places} />)
|
||||
// Marker still renders; base64 path in createPlaceIcon should be exercised
|
||||
expect(screen.getByTestId('marker')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-015: uses cached photo thumb from photoService when available', () => {
|
||||
vi.mocked(photoService.getCached).mockReturnValue({ thumbDataUrl: 'data:image/jpeg;base64,abc' } as any)
|
||||
const places = [
|
||||
buildMapPlace({ id: 20, lat: 48.0, lng: 2.0, google_place_id: 'gplace_123' }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
expect(screen.getByTestId('marker')).toBeTruthy()
|
||||
vi.mocked(photoService.getCached).mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-017: renders selected marker with higher z-index offset', () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 5, lat: 48.8584, lng: 2.2945 }),
|
||||
]
|
||||
render(<MapView places={places} selectedPlaceId={5} />)
|
||||
expect(screen.getByTestId('marker')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import ReservationOverlay from './ReservationOverlay'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -66,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
// Base64 data URL thumbnails — no external image fetch during zoom
|
||||
// Only use base64 data URLs for markers — external URLs cause zoom lag
|
||||
if (place.image_url && place.image_url.startsWith('data:')) {
|
||||
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback
|
||||
// while the thumb is still being generated in the background
|
||||
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) {
|
||||
const imgIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
@@ -275,97 +277,110 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
// Live-location rendering inside the Leaflet map. Subscribes via the
|
||||
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
|
||||
// identically. Heading is shown as a rotated conic SVG when available.
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||
|
||||
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
|
||||
const map = useMap()
|
||||
const [position, setPosition] = useState<[number, number] | null>(null)
|
||||
const [accuracy, setAccuracy] = useState(0)
|
||||
const [tracking, setTracking] = useState(false)
|
||||
const watchId = useRef<number | null>(null)
|
||||
|
||||
const startTracking = useCallback(() => {
|
||||
if (!('geolocation' in navigator)) return
|
||||
setTracking(true)
|
||||
watchId.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
|
||||
setPosition(latlng)
|
||||
setAccuracy(pos.coords.accuracy)
|
||||
},
|
||||
() => setTracking(false),
|
||||
{ enableHighAccuracy: true, maximumAge: 5000 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const stopTracking = useCallback(() => {
|
||||
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
|
||||
watchId.current = null
|
||||
setTracking(false)
|
||||
setPosition(null)
|
||||
}, [])
|
||||
|
||||
const toggleTracking = useCallback(() => {
|
||||
if (tracking) { stopTracking() } else { startTracking() }
|
||||
}, [tracking, startTracking, stopTracking])
|
||||
|
||||
// Center map on position when first acquired
|
||||
const centered = useRef(false)
|
||||
// When the user is in follow mode, keep the map centred on the dot.
|
||||
// setView (no animation) is what Google Maps does during navigation —
|
||||
// it feels responsive and avoids animation jitter at walking speed.
|
||||
useEffect(() => {
|
||||
if (position && !centered.current) {
|
||||
map.setView(position, 15)
|
||||
centered.current = true
|
||||
}
|
||||
}, [position, map])
|
||||
if (mode !== 'follow' || !position) return
|
||||
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 }) } catch { /* noop */ }
|
||||
}, [position, mode, map])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
|
||||
// Once, when the user first acquires a fix in "show" mode, pan to it so
|
||||
// they don't have to scroll the map. Subsequent fixes only move the dot.
|
||||
const centeredRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (mode === 'off') { centeredRef.current = false; return }
|
||||
if (!position || centeredRef.current) return
|
||||
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15)) } catch { /* noop */ }
|
||||
centeredRef.current = true
|
||||
}, [position, mode, map])
|
||||
|
||||
if (!position) return null
|
||||
|
||||
const headingIcon = position.heading === null || Number.isNaN(position.heading) ? null : L.divIcon({
|
||||
className: '',
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 30],
|
||||
html: `<div style="
|
||||
width:60px;height:60px;
|
||||
transform:rotate(${position.heading}deg);transition:transform 120ms ease-out;
|
||||
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
|
||||
border-radius:50%;
|
||||
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
pointer-events:none;
|
||||
"></div>`,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Location button */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
|
||||
}}>
|
||||
<button onClick={toggleTracking} style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Blue dot + accuracy circle */}
|
||||
{position && (
|
||||
<>
|
||||
{accuracy < 500 && (
|
||||
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
|
||||
)}
|
||||
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
|
||||
</>
|
||||
{position.accuracy < 500 && (
|
||||
<Circle
|
||||
center={[position.lat, position.lng]}
|
||||
radius={position.accuracy}
|
||||
pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.12, weight: 1, opacity: 0.35 }}
|
||||
interactive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pulse animation CSS */}
|
||||
{position && (
|
||||
<style>{`
|
||||
@keyframes location-pulse {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
{headingIcon && (
|
||||
<Marker
|
||||
position={[position.lat, position.lng]}
|
||||
icon={headingIcon}
|
||||
interactive={false}
|
||||
zIndexOffset={900}
|
||||
/>
|
||||
)}
|
||||
<CircleMarker
|
||||
center={[position.lat, position.lng]}
|
||||
radius={8}
|
||||
pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 3 }}
|
||||
interactive={false}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface MemoMarkerProps {
|
||||
place: any
|
||||
isSelected: boolean
|
||||
orderNumbers: number[] | null
|
||||
photoUrl: string | null
|
||||
onClickPlace: (id: number) => void
|
||||
onHover: (place: any, x: number, y: number) => void
|
||||
onHoverOut: () => void
|
||||
}
|
||||
|
||||
const MemoMarker = memo(function MemoMarker({
|
||||
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut,
|
||||
}: MemoMarkerProps) {
|
||||
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
|
||||
return (
|
||||
<Marker
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onClickPlace(place.id),
|
||||
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: onHoverOut,
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -384,7 +399,16 @@ export const MapView = memo(function MapView({
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
}) {
|
||||
reservations = [] as Reservation[],
|
||||
showReservationStats = false,
|
||||
visibleConnectionIds = [] as number[],
|
||||
onReservationClick,
|
||||
}: any) {
|
||||
const visibleReservations = useMemo(() => {
|
||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||
const set = new Set(visibleConnectionIds)
|
||||
return reservations.filter((r: Reservation) => set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds])
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
const paddingOpts = useMemo(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -396,22 +420,51 @@ export const MapView = memo(function MapView({
|
||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
}, [])
|
||||
|
||||
const handleMarkerHoverOut = useCallback(() => {
|
||||
setHoveredPlace(null)
|
||||
}, [])
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
onMarkerClick?.(id)
|
||||
}, [onMarkerClick])
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
// Batch photo state updates through a RAF so N simultaneous photo loads
|
||||
// collapse into a single re-render instead of N separate renders.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
|
||||
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0) return
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
iconCache.clear()
|
||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
if (place.image_url && place.image_url.startsWith('data:')) continue
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) continue
|
||||
|
||||
@@ -421,20 +474,28 @@ export const MapView = memo(function MapView({
|
||||
continue
|
||||
}
|
||||
|
||||
// Subscribe for when thumb becomes available
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
// Always fetch through API — returns fresh URL + converts to base64
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
const photoId =
|
||||
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|
||||
|| place.google_place_id
|
||||
|| place.osm_id
|
||||
|| place.image_url
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => cleanups.forEach(fn => fn())
|
||||
}, [placeIds])
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
@@ -446,57 +507,56 @@ export const MapView = memo(function MapView({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
<MemoMarker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
permanent={isTouchDevice && isSelected}
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
place={place}
|
||||
isSelected={isSelected}
|
||||
orderNumbers={orderNumbers}
|
||||
photoUrl={photoUrl}
|
||||
onClickPlace={handleMarkerClick}
|
||||
onHover={handleMarkerHover}
|
||||
onHoverOut={handleMarkerHoverOut}
|
||||
/>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
|
||||
|
||||
const gpxPolylines = useMemo(() => places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [(
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)]
|
||||
} catch { return [] }
|
||||
}), [places])
|
||||
|
||||
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full relative">
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
@@ -520,7 +580,7 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<LocationTracker />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
@@ -537,15 +597,18 @@ export const MapView = memo(function MapView({
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
{route && route.length > 0 && (
|
||||
<>
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
{route.map((seg, i) => seg.length > 1 && (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={seg}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
))}
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
@@ -553,22 +616,54 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{places.map((place) => {
|
||||
if (!place.route_geometry) return null
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return null
|
||||
return (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)
|
||||
} catch { return null }
|
||||
})}
|
||||
{gpxPolylines}
|
||||
|
||||
<ReservationOverlay
|
||||
reservations={visibleReservations}
|
||||
showConnections
|
||||
showStats={showReservationStats}
|
||||
onEndpointClick={onReservationClick}
|
||||
/>
|
||||
</MapContainer>
|
||||
{isMobile && <LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={locationButtonBottom as unknown as number}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
{TooltipOverlay && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x + 14,
|
||||
top: tooltipPos.y - 10,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||
padding: '6px 10px',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
maxWidth: 220,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.name}
|
||||
</div>
|
||||
{hoveredPlace.category_name && CatIcon && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredPlace.address && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import { MapViewGL } from './MapViewGL'
|
||||
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MapViewAuto(props: any) {
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||
// so trip planner never shows an empty map due to a missing token.
|
||||
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
|
||||
return <MapView {...props} />
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
interface RouteSegment {
|
||||
mid: [number, number]
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
walkingText?: string
|
||||
drivingText?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
places: Place[]
|
||||
dayPlaces?: Place[]
|
||||
route?: [number, number][][] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
selectedPlaceId?: number | null
|
||||
onMarkerClick?: (id: number) => void
|
||||
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
fitKey?: number | null
|
||||
dayOrderMap?: Record<number, number[] | null>
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
hasInspector?: boolean
|
||||
hasDayDetail?: boolean
|
||||
reservations?: Reservation[]
|
||||
visibleConnectionIds?: number[]
|
||||
showReservationStats?: boolean
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
const size = selected ? 44 : 36
|
||||
const borderColor = selected ? '#111827' : 'white'
|
||||
const borderWidth = selected ? 3 : 2.5
|
||||
const shadow = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const bgColor = place.category_color || '#6b7280'
|
||||
|
||||
// The visual circle is `size` + 2*border on each side. To make the
|
||||
// mapbox `anchor: 'center'` land on the real visual middle of the marker
|
||||
// (rather than just the inner content box), the wrapper has to be the
|
||||
// full outer size. If we gave the wrapper only `size`, the border would
|
||||
// bleed outside it and the route lines would appear slightly off.
|
||||
const outer = size + borderWidth * 2
|
||||
|
||||
let badgeHtml = ''
|
||||
if (orderNumbers && orderNumbers.length > 0) {
|
||||
const label = orderNumbers.join(' · ')
|
||||
badgeHtml = `<span style="
|
||||
position:absolute;bottom:-2px;right:-2px;
|
||||
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
|
||||
padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
|
||||
background:rgba(255,255,255,0.94);
|
||||
border:1.5px solid rgba(0,0,0,0.15);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||
box-sizing:border-box;white-space:nowrap;
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
// Do NOT set `position: relative` here — mapbox-gl ships
|
||||
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
|
||||
// `position: relative` here overrides the class, turns every marker into
|
||||
// a static block element, and stacks them in document order inside the
|
||||
// canvas container. The result looks exactly like "markers drift as the
|
||||
// map zooms" because each marker's transform is then applied relative
|
||||
// to its stacked slot, not to the map viewport.
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
|
||||
|
||||
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'))
|
||||
if (hasPhoto) {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:hidden;background:${bgColor};
|
||||
box-sizing:content-box;
|
||||
">
|
||||
<img src="${photoUrl}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
`
|
||||
} else {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
background:${bgColor};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-sizing:content-box;
|
||||
">
|
||||
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
|
||||
</div>
|
||||
${badgeHtml}
|
||||
`
|
||||
}
|
||||
return wrap
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
onMapContextMenu = null,
|
||||
center = [48.8566, 2.3522],
|
||||
zoom = 10,
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
showReservationStats = false,
|
||||
onReservationClick,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
// Refs so the reservation overlay always sees the latest callback /
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
onClickRefs.current.map = onMapClick
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
|
||||
// Build/rebuild the map on style/token/3d change
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: containerRef.current,
|
||||
style: mapboxStyle,
|
||||
center: [center[1], center[0]],
|
||||
zoom,
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).__trek_map = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
// Terrain is only valuable on satellite styles — on clean vector
|
||||
// styles it makes route lines drift off the HTML markers because
|
||||
// the lines snap to DEM height while markers stay at sea level.
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(mapboxStyle)) {
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
addCustom3dBuildings(map, dark)
|
||||
}
|
||||
}
|
||||
|
||||
// Mapbox Standard ships its own DEM-based terrain that kicks in
|
||||
// below zoom 13.7. HTML markers project at sea level, so when the
|
||||
// terrain exaggeration ramps up at lower zooms the markers drift
|
||||
// away from the 3D buildings and route lines they belong to. The
|
||||
// non-satellite Standard style still looks great without terrain,
|
||||
// so flatten it out to keep markers pinned. (Satellite variants
|
||||
// are left alone — the DEM is what gives them their character.)
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: {
|
||||
'line-color': '#111827',
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.9,
|
||||
'line-dasharray': [2, 1.5],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
// gpx geometries source (place.route_geometry)
|
||||
if (!map.getSource('trip-gpx')) {
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-gpx-line',
|
||||
type: 'line',
|
||||
source: 'trip-gpx',
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
|
||||
'line-width': 3.5,
|
||||
'line-opacity': 0.75,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
// Signal that sources/layers are attached so overlay effects can
|
||||
// safely add their own sources. Style rebuilds reset this via the
|
||||
// cleanup below.
|
||||
setMapReady(true)
|
||||
})
|
||||
|
||||
map.on('click', (e) => {
|
||||
const t = e.originalEvent.target as HTMLElement
|
||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// In the mapbox-gl map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
const canvas = map.getCanvasContainer()
|
||||
const onAuxDown = (ev: MouseEvent) => {
|
||||
if (ev.button !== 1) return
|
||||
ev.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
||||
onClickRefs.current.context?.({
|
||||
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
||||
originalEvent: ev,
|
||||
})
|
||||
}
|
||||
// Also suppress the browser's native auxclick menu on middle-click.
|
||||
const onAuxClick = (ev: MouseEvent) => {
|
||||
if (ev.button === 1) ev.preventDefault()
|
||||
}
|
||||
canvas.addEventListener('mousedown', onAuxDown)
|
||||
canvas.addEventListener('auxclick', onAuxClick)
|
||||
|
||||
// Drop follow mode if the user pans the map manually — matches the
|
||||
// Apple Maps behaviour where the blue dot stays but the map no longer
|
||||
// chases it until the user taps the button again.
|
||||
map.on('dragstart', () => {
|
||||
setTrackingMode(prev => prev === 'follow' ? 'show' : prev)
|
||||
})
|
||||
|
||||
// Keep HTML markers glued to the terrain / 3D ground. Mapbox projects
|
||||
// HTML markers at altitude=0 (sea level) by default, so as soon as the
|
||||
// style has a terrain DEM (Standard, Standard Satellite, custom terrain)
|
||||
// the markers drift off the places when the camera pitches or zooms —
|
||||
// the buildings rise from DEM height, the marker stays at sea level,
|
||||
// and the pixel offset grows as the perspective changes.
|
||||
//
|
||||
// Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to
|
||||
// project the marker onto the same ground the route line sits on.
|
||||
// We re-apply this every render because DEM tiles stream in async.
|
||||
let lastAltUpdate = 0
|
||||
const syncMarkerAltitudes = () => {
|
||||
const now = performance.now()
|
||||
if (now - lastAltUpdate < 80) return // ~12Hz is plenty
|
||||
lastAltUpdate = now
|
||||
markersRef.current.forEach(marker => {
|
||||
const ll = marker.getLngLat()
|
||||
let alt = 0
|
||||
try {
|
||||
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
||||
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
||||
} catch { /* terrain not ready */ }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const curAlt = (ll as any).alt ?? 0
|
||||
if (Math.abs(curAlt - alt) > 0.25) {
|
||||
marker.setLngLat([ll.lng, ll.lat, alt])
|
||||
}
|
||||
})
|
||||
}
|
||||
map.on('render', syncMarkerAltitudes)
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onAuxDown)
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (reservationOverlayRef.current) {
|
||||
reservationOverlayRef.current.destroy()
|
||||
reservationOverlayRef.current = null
|
||||
}
|
||||
if (locationMarkerRef.current) {
|
||||
locationMarkerRef.current.destroy()
|
||||
locationMarkerRef.current = null
|
||||
}
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
setMapReady(false)
|
||||
}
|
||||
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
|
||||
|
||||
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
||||
// simultaneous thumb arrivals into one re-render.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) continue
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached?.thumbDataUrl) {
|
||||
setThumb(cacheKey, cached.thumbDataUrl)
|
||||
continue
|
||||
}
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId =
|
||||
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|
||||
|| place.google_place_id
|
||||
|| place.osm_id
|
||||
|| place.image_url
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reconcile markers with places + photos. Rebuilds the DOM node when any
|
||||
// visual input changes so photos, selection state and order badges stay
|
||||
// in sync.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const ids = new Set(places.map(p => p.id))
|
||||
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
if (!ids.has(id)) {
|
||||
marker.remove()
|
||||
markersRef.current.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
places.forEach(place => {
|
||||
if (!place.lat || !place.lng) return
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const selected = place.id === selectedPlaceId
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
// Recreate marker each time rather than patching internal state —
|
||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||
const existing = markersRef.current.get(place.id)
|
||||
if (existing) existing.remove()
|
||||
// Default (viewport-aligned) anchors keep the marker parallel to the
|
||||
// screen so its pixel centre lines up with the route line at any
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
// but it rotates the element by the pitch angle and visually offsets
|
||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([place.lng, place.lat])
|
||||
.addTo(map)
|
||||
markersRef.current.set(place.id, m)
|
||||
})
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||
|
||||
// Update route geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}))
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route])
|
||||
|
||||
// Update GPX geometries
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [{
|
||||
type: 'Feature' as const,
|
||||
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
}]
|
||||
} catch { return [] }
|
||||
})
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [places])
|
||||
|
||||
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
|
||||
// circle arcs for flights/cruises, straight lines for trains/cars,
|
||||
// clickable endpoint badges, rotating mid-arc stats label for flights.
|
||||
// The overlay is a small imperative manager that owns its own source,
|
||||
// layer, and HTML markers; it lives next to the map for the map's
|
||||
// lifetime and is rebuilt when the style/token/3d effect rebuilds.
|
||||
//
|
||||
// `visibleConnectionIds` is driven by the per-reservation toggle in
|
||||
// DayPlanSidebar — nothing is rendered until the user enables a
|
||||
// booking's route, matching the Leaflet MapView's behaviour.
|
||||
const visibleReservations = useMemo(() => {
|
||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||
const set = new Set(visibleConnectionIds)
|
||||
return reservations.filter(r => set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
if (!reservationOverlayRef.current) {
|
||||
reservationOverlayRef.current = new ReservationMapboxOverlay(map, {
|
||||
showConnections: true,
|
||||
showStats: showReservationStats,
|
||||
showEndpointLabels,
|
||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||
})
|
||||
}
|
||||
reservationOverlayRef.current.update(visibleReservations, {
|
||||
showConnections: true,
|
||||
showStats: showReservationStats,
|
||||
showEndpointLabels,
|
||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||
})
|
||||
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
|
||||
|
||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||
const paddingOpts = useMemo(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
|
||||
const top = 60
|
||||
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Also fit when the places collection changes so the initial render
|
||||
// zooms to the trip instead of the default center.
|
||||
const placeBoundsKey = useMemo(
|
||||
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
|
||||
[places]
|
||||
)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||
const valid = target.filter(p => p.lat && p.lng)
|
||||
if (valid.length === 0) return
|
||||
const bounds = new mapboxgl.LngLatBounds()
|
||||
valid.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||
const run = () => {
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
if (map.loaded()) run()
|
||||
else map.once('load', run)
|
||||
}, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !selectedPlaceId) return
|
||||
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
|
||||
if (!target?.lat || !target?.lng) return
|
||||
try {
|
||||
map.flyTo({
|
||||
center: [target.lng, target.lat],
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// External center/zoom prop changes — jump without animation
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
try { map.jumpTo({ center: [center[1], center[0]], zoom }) } catch { /* noop */ }
|
||||
}, [center[0], center[1]]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Blue dot rendering + follow-mode camera. Attach the marker lazily the
|
||||
// first time a fix arrives so the layers sit on top of everything else
|
||||
// added so far, and destroy it when tracking is turned off.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
if (trackingMode === 'off') {
|
||||
if (locationMarkerRef.current) {
|
||||
locationMarkerRef.current.update(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!userPosition) return
|
||||
const apply = () => {
|
||||
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
|
||||
locationMarkerRef.current.update(userPosition)
|
||||
if (trackingMode === 'follow') {
|
||||
// easeTo is gentler than flyTo for continuous updates
|
||||
try {
|
||||
map.easeTo({
|
||||
center: [userPosition.lng, userPosition.lat],
|
||||
bearing: userPosition.heading ?? map.getBearing(),
|
||||
zoom: Math.max(map.getZoom(), 16),
|
||||
duration: 350,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
if (map.loaded()) apply()
|
||||
else map.once('load', apply)
|
||||
}, [userPosition, trackingMode])
|
||||
|
||||
if (!mapboxToken) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
||||
<div className="text-sm text-zinc-500">
|
||||
No Mapbox access token configured.<br />
|
||||
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
{isMobile && (
|
||||
<LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={buttonBottom as unknown as number}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user