mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
518 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6491e1f986 | |||
| 03757ed0af | |||
| a676dbe881 | |||
| 411d8620ba | |||
| f45f56318a | |||
| 3ae0f3f819 | |||
| 306626ee1c | |||
| 7e0fe3b1b9 | |||
| fdbc015dbf | |||
| 7d8e3912b4 | |||
| 9ebca725ae | |||
| 9718187490 | |||
| aa0620e01f | |||
| 955776b492 | |||
| 9b11abbf4a | |||
| cc613771fa | |||
| 5cc81ae4b0 | |||
| 94b74f96a3 | |||
| 48bf149d01 | |||
| f3679739d8 | |||
| 38206883ff | |||
| dd21074c27 | |||
| cd5a6c7491 | |||
| 6e6e0a370e | |||
| 83bac11173 | |||
| ecf69225e1 | |||
| c6148ba4f2 | |||
| 9ee5d21c3a | |||
| d5cc2432c4 | |||
| 7f077d949d | |||
| 312bc715bf | |||
| 6ba08352ed | |||
| 58874a1ccb | |||
| 82f08360d7 | |||
| 978d26f36c | |||
| 18eee16d2d | |||
| c274846275 | |||
| 7821993450 | |||
| a9d6ce87c1 | |||
| 67b21d5fe3 | |||
| 8b488efc8e | |||
| 070b75b6be | |||
| 51c4afd5f7 | |||
| 74b3b0f9ae | |||
| 1236f3281d | |||
| 4a0d586768 | |||
| 079964bec8 | |||
| b0b85fff3a | |||
| 0d3a10120a | |||
| b8c3d5b3d1 | |||
| 959015928f | |||
| d8ee545002 | |||
| 78b9536de9 | |||
| 4e4afe2545 | |||
| 38afba0820 | |||
| 81742dbb85 | |||
| 3898e5f7e2 | |||
| 6a36efbf1a | |||
| 991b4065e3 | |||
| c158df1bc5 | |||
| f03705848d | |||
| 0c99eb1d07 | |||
| 7b37d337c1 | |||
| 69ae6f93db | |||
| 71c1683bb3 | |||
| 6df8b2555d | |||
| 16cadeb09e | |||
| fc29c5f7d0 | |||
| 399684cc19 | |||
| a038dbd8da | |||
| f225f45f50 | |||
| 58b7c2e7ac | |||
| b8058a2755 | |||
| aa244dd548 | |||
| 33d8953554 | |||
| c39ae2b965 | |||
| 3413d3f77d | |||
| c9e3185ad0 | |||
| f8cf37a9bd | |||
| 20709d23ee | |||
| e4065c276b | |||
| 11b6974387 | |||
| 554a7d7530 | |||
| 2baf407809 | |||
| 259ff53bfb | |||
| 21063e6230 | |||
| 1285da063e | |||
| 3e9e3fcc9e | |||
| ba4bfc693a | |||
| 179938e904 | |||
| 4e13a59338 | |||
| 2c9e71c91d | |||
| 733567d088 | |||
| 5b25c60b62 | |||
| d7efa9d914 | |||
| c70f5284c7 | |||
| b40bea036f | |||
| 6da7843bf0 | |||
| 9f0ec8199f | |||
| 9bff25558e | |||
| 00b96eb678 | |||
| 3d0249e076 | |||
| 1bddb3c588 | |||
| b26023e32a | |||
| c8421eb1fc | |||
| 8c125738e8 | |||
| 6d92e14515 | |||
| 0b36427c09 | |||
| 1ea0eb9965 | |||
| c4c3ea1e6d | |||
| 43c801232e | |||
| 6825a4a0c1 | |||
| 8a4a8b58be | |||
| be975f38a6 | |||
| fa37d5b3f7 | |||
| 0ddd0c14b2 | |||
| 297cfda32b | |||
| d8367ec878 | |||
| 79057327fa | |||
| 0943184b1e | |||
| 3f612c4d26 | |||
| a4752ae692 | |||
| e6068d44b0 | |||
| 877e1a09cc | |||
| bca82b3f8c | |||
| 1aea2fcee8 | |||
| 504713d920 | |||
| 50d2a211e5 | |||
| 5d3a740791 | |||
| 2c1c77f367 | |||
| 68f0d399ca | |||
| 1305a07502 | |||
| c9dd8e1192 | |||
| 860739b28b | |||
| 80d013dd19 | |||
| 2469739bca | |||
| 2197e0e1fd | |||
| 846db9d076 | |||
| a307d8d1c9 | |||
| ae0d48ac83 | |||
| 6400c2d27d | |||
| fc28996420 | |||
| 929105f0e4 | |||
| 93c0d6fe78 | |||
| 88a40c3294 | |||
| c056401000 | |||
| eae799c7d6 | |||
| 20ce7460c1 | |||
| d765a80ea3 | |||
| b6686a462f | |||
| 9ddb101135 | |||
| 1dc189b466 | |||
| e624ee337f | |||
| 6ba5df0215 | |||
| 897e1bff26 | |||
| ba14636c1d | |||
| 6c72295424 | |||
| f6faaa23b0 | |||
| ba737a9920 | |||
| 98813a9b40 | |||
| e0105115f4 | |||
| 7d51eadf90 | |||
| 66740887e7 | |||
| 69deaf9969 | |||
| 217458da81 | |||
| 61a5e42403 | |||
| 07546c4790 | |||
| f4f768a1b3 | |||
| a9c392e26e | |||
| 90af1332e8 | |||
| de4bdb4a99 | |||
| 8dd22ab8a3 | |||
| fa25ff29bb | |||
| 21f87d9b91 | |||
| 0115987e52 | |||
| 6c138ca924 | |||
| 1adc2fec86 | |||
| 8c7f8d6ad1 | |||
| 2ae9da3153 | |||
| b4741c31a9 | |||
| cfdbf9235f | |||
| 059158d087 | |||
| 77393ff40b | |||
| 64d4a20403 | |||
| 6b94c0632c | |||
| cb124ba3ec | |||
| ba01b4acac | |||
| ce72f45d9a | |||
| bf2eea18c3 | |||
| 501bab0f69 | |||
| 5dd80d5cb8 | |||
| 8f6de3cd23 | |||
| 816696d0fe | |||
| bb54fda6dc | |||
| 36f2292f2d | |||
| 905c7d460b | |||
| d48714d17a | |||
| a0db42fbfe | |||
| 82a3940a2c | |||
| b224f8b713 | |||
| be03fffcae | |||
| 1e27a62b53 | |||
| d418d85d02 | |||
| a7d3f9fc06 | |||
| 7a169d0596 | |||
| cf968969d0 | |||
| c20d0256c8 | |||
| c4236d6737 | |||
| 4b8cfc78b8 | |||
| f7c965bc6b | |||
| 78a91ccb95 | |||
| 8e9f8784dc | |||
| 5be2e9b268 | |||
| f4d0ccb454 | |||
| a40983e65e | |||
| f32c103fe1 | |||
| 0b77fe5292 | |||
| 9afb51fcc0 | |||
| 4e10028669 | |||
| d4e16ebe49 | |||
| 1e44b25a0c | |||
| 4ff03a1f2c | |||
| 40f7c00adb | |||
| b43d8d119f | |||
| 74e3f85866 | |||
| bbf3f0cae8 | |||
| c0e9a771d6 | |||
| c49272efc1 | |||
| 979322025d | |||
| f0131632a7 | |||
| ffe91604b5 | |||
| e7fa8f5da9 | |||
| 3256f5156d | |||
| d45073a0bd | |||
| a4d6348a79 | |||
| c944a7d101 | |||
| 45e0c7e546 | |||
| 32b63adc68 | |||
| b1cca15f6f | |||
| dfeb7b3db7 | |||
| 50424fc574 | |||
| 12a910876e | |||
| d73a5e223c | |||
| fd9567e3fe | |||
| ae04071466 | |||
| 2ab3f59722 | |||
| 7257fac859 | |||
| 1a4c04e239 | |||
| 39a495714f | |||
| fabf5a7e26 | |||
| e71bd6768e | |||
| 71403e6303 | |||
| 43fc4db00e | |||
| e9ee2d4b0d | |||
| 228cb05932 | |||
| 505bf04a1f | |||
| 41bfcf2f76 | |||
| e308204808 | |||
| 411d5408c1 | |||
| 45684d9e44 | |||
| 0ebcff9504 | |||
| edafe01387 | |||
| 16277a3811 | |||
| ef5b381f8e | |||
| ef9880a2a5 | |||
| 95cb81b0e5 | |||
| 7d0ae631b8 | |||
| 5c04074d54 | |||
| e89ba2ecfc | |||
| 4ebf9c5f11 | |||
| add0b17e04 | |||
| 60906cf1d1 | |||
| 9292acb979 | |||
| be57b7130f | |||
| b88a8fcbb5 | |||
| 040840917c | |||
| 44e5f07f59 | |||
| c9e61859ce | |||
| 862f59b77a | |||
| 871bfd7dfd | |||
| 4d596f2ff9 | |||
| 8c85ea3644 | |||
| 19350fbc3e | |||
| 358afd2428 | |||
| 7a314a92b1 | |||
| e03505dca2 | |||
| ce8d498f2d | |||
| b109c1340a | |||
| e10f6bf9af | |||
| 6f5550dc50 | |||
| dfdd473eca | |||
| b515880adb | |||
| 78695b4e03 | |||
| 0ee53e7b38 | |||
| 1b28bd96d4 | |||
| bba50f038b | |||
| 701a8ab03a | |||
| ccb5f9df1f | |||
| c9341eda3f | |||
| fb2e8d8209 | |||
| 27fb9246e6 | |||
| 9a2c7c5db6 | |||
| d1ad5da919 | |||
| 1fbc19ad4f | |||
| 23edfe3dfc | |||
| 1ff8546484 | |||
| 6d18d5ed2d | |||
| 6d5067247c | |||
| 5e05bcd0db | |||
| 5f71b85c06 | |||
| d74133745a | |||
| eee2bbe47a | |||
| c1bce755ca | |||
| 015be3d53a | |||
| 7d3b37a2a3 | |||
| ff1c1ed56a | |||
| d5674e9a11 | |||
| 7eabe65bcf | |||
| 3444e3f446 | |||
| 9e3ac1e490 | |||
| c38e70e244 | |||
| ce7215341f | |||
| 4733955531 | |||
| 36267de117 | |||
| cd13399da5 | |||
| 36cd2feca5 | |||
| fbe3b5b17e | |||
| 10107ecf31 | |||
| 94d698e39f | |||
| 6c88a01123 | |||
| 75af89de30 | |||
| ed8518aca4 | |||
| 7522f396e7 | |||
| 9b2f083e4b | |||
| 9a949d7391 | |||
| 13904fb702 | |||
| f7160e6dec | |||
| 1983691950 | |||
| 6866644d0c | |||
| b120aabaa3 | |||
| 1d442c1d7a | |||
| 9a0294360c | |||
| 9de0c5b051 | |||
| 9e9b86f1b4 | |||
| 8ff5ec486f | |||
| 5576339bcc | |||
| e668e80f1c | |||
| 3aaa6e916b | |||
| ad329eddb9 | |||
| 990e804bd3 | |||
| 299e26bebe | |||
| 96b6d7d81f | |||
| 27d5c3400c | |||
| bb9c0c9b68 | |||
| 483190e7c1 | |||
| c89ff8b551 | |||
| 63232e56a3 | |||
| 643504d89b | |||
| 2288f9d2fc | |||
| 804c2586a9 | |||
| fedd559fd6 | |||
| 5f07bdaaf1 | |||
| fb643a1ade | |||
| 069fd99341 | |||
| 3dc760484a | |||
| 13580ea5fb | |||
| aa5dd1abc6 | |||
| de444bf770 | |||
| 821f71ac28 | |||
| faebc62917 | |||
| 41e572445c | |||
| 66f5ea50c5 | |||
| ce4b8088ec | |||
| b1138eb9db | |||
| 8412f303dd | |||
| 7272e0bbfd | |||
| c7eaf3aa79 | |||
| deef5e6b81 | |||
| 6d72006b28 | |||
| 26c1676cdd | |||
| 4ddfa92c14 | |||
| 19c9e17884 | |||
| 14ef2d4a4a | |||
| de859318fa | |||
| bcbb516448 | |||
| 71870e4567 | |||
| 9819473157 | |||
| eb7984f40d | |||
| 9caa0acc24 | |||
| 8ddfa8fde0 | |||
| 41d4b2a8be | |||
| 10ebf46a98 | |||
| 70809d6c27 | |||
| a314ba2b80 | |||
| d8f03f6bea | |||
| 533d6f84d8 | |||
| 095cb1b9d1 | |||
| 0a0205fcf9 | |||
| 9aed5ff2ed | |||
| d189d6d776 | |||
| 262905e357 | |||
| 4a4643f33f | |||
| a6a7edf0b2 | |||
| 949d0967d2 | |||
| cd634093af | |||
| 7201380504 | |||
| ba87a7f876 | |||
| 9f1b0554d6 | |||
| 1166a09835 | |||
| 6f2d7c8f5e | |||
| e6c4c22a1d | |||
| 9a044ada28 | |||
| da5e77f78d | |||
| cc8be328f9 | |||
| f1c4155d81 | |||
| d4899a8dee | |||
| a973a1b4f8 | |||
| 73b0534053 | |||
| 931c5bd990 | |||
| ee54308819 | |||
| 66b00c24e2 | |||
| f6d08582ec | |||
| 8d9a511edf | |||
| 3059d53d11 | |||
| 3074724f2f | |||
| 21ed7ea4a2 | |||
| 267271d97a | |||
| 874c1292c7 | |||
| a9948499e4 | |||
| 3dd15499e6 | |||
| 393e99201a | |||
| 153b7f64b7 | |||
| 7b2d45665c | |||
| 37873dd938 | |||
| 90301e62ce | |||
| 377422a9d5 | |||
| d90a059dfa | |||
| 1e20f024d5 | |||
| 9a81baa809 | |||
| 11b85a2d70 | |||
| d04629605e | |||
| 187989cc1d | |||
| 6444b2b4ce | |||
| 42ebc7c298 | |||
| 8bca921b30 | |||
| 12f8b6eb55 | |||
| 202cfb6a63 | |||
| b6f9664ec2 | |||
| 9f8075171d | |||
| 02b907e764 | |||
| e05e021f41 | |||
| 615c6bae58 | |||
| 62fbc26811 | |||
| 2171203a4c | |||
| b28b483b90 | |||
| 020cafade1 | |||
| e4b2262d4d | |||
| d2efd960b5 | |||
| c51a27371b | |||
| 252d2d22a8 | |||
| 80c2486570 | |||
| 7dcd89fb71 | |||
| 8458481950 | |||
| 808b7f7a72 | |||
| f4ee7b868d | |||
| e99960c3b6 | |||
| c39d242cfb | |||
| 2f8a189319 | |||
| 44138af11a | |||
| bc6c59f358 | |||
| 54804d0e5f | |||
| 631e47944b | |||
| 3abcc0ec76 | |||
| 530f233b7d | |||
| fbb3bb862c | |||
| 3c3b7b9136 | |||
| 99514ddce1 | |||
| b0ffb63d67 | |||
| d909aac751 | |||
| e91b79ebfc | |||
| 2d7babcba3 | |||
| e56ea068ef | |||
| a091051387 | |||
| df3e62af5c | |||
| 399e4acf03 | |||
| e0fd9830d9 | |||
| 7a445583d7 | |||
| 1d9d628e2d | |||
| 005c08dcea | |||
| e25fec4e4a | |||
| 85e69b8a3d | |||
| 1d57eacfa4 | |||
| ecf7433980 | |||
| 433d780f74 | |||
| 27f8856e9b | |||
| f2c90ee0f4 | |||
| 83d256ebac | |||
| 3c4f5f7193 | |||
| 31124a604a | |||
| 0d9dbb6286 | |||
| 66ae577b7b | |||
| 706548c45d | |||
| aa32df5ee1 | |||
| 1f9ae8e4b5 | |||
| d69585a820 | |||
| 723f8a1c3d | |||
| 678fe2d12c | |||
| e97ecd558f | |||
| 3d33191925 | |||
| 48e1b732d8 | |||
| d50c84b755 | |||
| fcbfeb6793 | |||
| 77f2c616de | |||
| 9f8d3f8d99 | |||
| 3f26a68f64 | |||
| a3b6a89471 | |||
| ee54d89144 | |||
| b6d927a3d6 |
+23
-1
@@ -5,6 +5,28 @@ client/dist
|
||||
data
|
||||
uploads
|
||||
.git
|
||||
.env
|
||||
.github
|
||||
**/.env
|
||||
**/.env.*
|
||||
*.log
|
||||
*.md
|
||||
!client/**/*.md
|
||||
chart/
|
||||
docs/
|
||||
docker-compose.yml
|
||||
unraid-template.xml
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
**/coverage
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode
|
||||
.idea
|
||||
sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Normalize line endings to LF on commit
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicitly enforce LF for source files
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve TREK
|
||||
title: "[BUG] "
|
||||
labels: []
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Pre-flight checklist
|
||||
options:
|
||||
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: TREK version
|
||||
description: Found in the Settings → About, or in the Docker image tag
|
||||
placeholder: "e.g. 2.8.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: When I do X, Y happens instead of Z…
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Step-by-step instructions to reliably trigger the bug.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment method
|
||||
options:
|
||||
- Docker Compose
|
||||
- Docker (standalone)
|
||||
- Kubernetes / Helm
|
||||
- Unraid template
|
||||
- Sources
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Host OS
|
||||
placeholder: "e.g. Ubuntu 24.04, Unraid 6.12, Synology DSM 7"
|
||||
|
||||
- type: dropdown
|
||||
id: user_os
|
||||
attributes:
|
||||
label: Accessing TREK from
|
||||
options:
|
||||
- Desktop browser
|
||||
- Mobile browser
|
||||
- Mobile app (PWA)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser (if applicable)
|
||||
placeholder: "e.g. Chrome 124, Firefox 125, Safari 17"
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs or error output
|
||||
description: Paste any relevant server or browser console output here.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: Drag and drop screenshots here if applicable.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Anything else that might help us understand the issue.
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/mauriceboe/TREK/wiki
|
||||
about: Check the docs before opening an issue
|
||||
- name: Feature Request
|
||||
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
|
||||
about: Suggest a new feature or improvement in Discussions
|
||||
- name: Questions & Help
|
||||
url: https://github.com/mauriceboe/TREK/discussions
|
||||
about: For questions and general help, use Discussions instead
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Close untitled issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close if title is empty or generic
|
||||
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();
|
||||
|
||||
if (badTitles.includes(titleLower)) {
|
||||
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."
|
||||
});
|
||||
|
||||
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."
|
||||
});
|
||||
|
||||
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"
|
||||
});
|
||||
}
|
||||
@@ -3,11 +3,72 @@ name: Build & Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
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)
|
||||
|
||||
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
|
||||
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
|
||||
BUMP="patch"
|
||||
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)"
|
||||
|
||||
# Update both package.json files
|
||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
|
||||
# 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 commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
needs: version-bump
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -21,6 +82,8 @@ jobs:
|
||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -35,7 +98,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=mauriceboe/nomad,push-by-digest=true,name-canonical=true,push=true
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
|
||||
- name: Export digest
|
||||
@@ -54,8 +117,12 @@ jobs:
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [version-bump, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download build digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -73,8 +140,14 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
mapfile -t digests < <(printf 'mauriceboe/nomad@sha256:%s\n' *)
|
||||
docker buildx imagetools create -t mauriceboe/nomad:latest "${digests[@]}"
|
||||
VERSION=${{ needs.version-bump.outputs.version }}
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
docker buildx imagetools create \
|
||||
-t mauriceboe/trek:latest \
|
||||
-t mauriceboe/trek:$VERSION \
|
||||
-t mauriceboe/nomad:latest \
|
||||
-t mauriceboe/nomad:$VERSION \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
run: docker buildx imagetools inspect mauriceboe/nomad:latest
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd server && npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
@@ -11,6 +11,9 @@ client/public/icons/*.png
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# User data
|
||||
server/data/
|
||||
@@ -28,6 +31,7 @@ Thumbs.db
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -52,3 +56,6 @@ coverage
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
test-data
|
||||
@@ -0,0 +1,57 @@
|
||||
# Contributing to TREK
|
||||
|
||||
Thanks for your interest in contributing! Please read these guidelines before opening a pull request.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). 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
|
||||
|
||||
## Pull Requests
|
||||
|
||||
### Your PR should include:
|
||||
|
||||
- **Summary** — What does this change and why? (1-3 bullet points)
|
||||
- **Test plan** — How did you verify it works?
|
||||
- **Linked issue** — Reference the issue (e.g. `Fixes #123`)
|
||||
|
||||
### Your PR will be closed if it:
|
||||
|
||||
- Wasn't discussed and approved in `#github-pr` on Discord first
|
||||
- Introduces breaking changes
|
||||
- Adds unnecessary complexity or features beyond scope
|
||||
- Reformats or refactors unrelated code
|
||||
- Adds dependencies without clear justification
|
||||
|
||||
### Commit messages
|
||||
|
||||
Use [conventional commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
fix(maps): correct zoom level on Safari
|
||||
feat(budget): add CSV export for expenses
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
```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.
|
||||
|
||||
## More Details
|
||||
|
||||
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
+12
-14
@@ -1,4 +1,4 @@
|
||||
# Stage 1: React Client bauen
|
||||
# Stage 1: Build React client
|
||||
FROM node:22-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
@@ -6,34 +6,32 @@ RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Produktions-Server
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache python3 make g++ && \
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
|
||||
# Server-Code kopieren
|
||||
COPY server/ ./
|
||||
|
||||
# Gebauten Client kopieren
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
|
||||
# Fonts für PDF-Export kopieren
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
|
||||
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
|
||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app
|
||||
|
||||
# Umgebung setzen
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "--import", "tsx", "src/index.ts"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# MCP Integration
|
||||
|
||||
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
|
||||
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
|
||||
structured API.
|
||||
|
||||
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
- [Example](#example)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Enable the MCP addon (admin)
|
||||
|
||||
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
|
||||
|
||||
### 2. Create an API token
|
||||
|
||||
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
|
||||
|
||||
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
|
||||
|
||||
Each user can create up to **10 tokens**.
|
||||
|
||||
### 3. Configure your MCP client
|
||||
|
||||
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
|
||||
`claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer trek_your_token_here"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
---
|
||||
|
||||
## Limitations & Important Notes
|
||||
|
||||
| Limitation | Details |
|
||||
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
|
||||
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
|
||||
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
|
||||
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
|
||||
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
|
||||
| **Rate limiting** | 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. |
|
||||
| **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. |
|
||||
|
||||
---
|
||||
|
||||
## Resources (read-only)
|
||||
|
||||
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
|
||||
making changes.
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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.** |
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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"). |
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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. |
|
||||
|
||||
### Collab Notes
|
||||
|
||||
| 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. |
|
||||
|
||||
### Bucket List
|
||||
|
||||
| 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
|
||||
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
|
||||
|
||||
Initial prompt (1st message):
|
||||
|
||||
```
|
||||
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
|
||||
and leaving April 11 2027. It's cherry blossom season so please keep that
|
||||
in mind when picking spots.
|
||||
|
||||
Before writing anything to TREK, do some research: look up what's worth
|
||||
visiting, figure out a logical day-by-day flow (group nearby spots together
|
||||
to avoid unnecessary travel), find a well-reviewed hotel in a central
|
||||
neighbourhood, and think about what kind of food and restaurant experiences
|
||||
are worth including.
|
||||
|
||||
Once you have a solid plan, write the whole thing to TREK:
|
||||
- Create the trip
|
||||
- Add all the places you've researched with their real coordinates
|
||||
- Build out the daily itinerary with sensible visiting times
|
||||
- Book the hotel as a reservation and link it properly to the accommodation days
|
||||
- Add any notable restaurant reservations
|
||||
- Put together a realistic budget in EUR
|
||||
- Build a packing list suited to April in Kyoto
|
||||
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
|
||||
- Add a day note for each day with any important heads-up (early start, crowd
|
||||
tips, booking requirements, etc.)
|
||||
- Mark Japan as visited in my Atlas
|
||||
|
||||
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
|
||||
of everything that was added.
|
||||
```
|
||||
|
||||
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
|
||||
|
||||

|
||||
@@ -2,27 +2,28 @@
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
||||
<img src="client/public/logo-light.svg" alt="NOMAD" height="60" />
|
||||
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
|
||||
</picture>
|
||||
<br />
|
||||
<em>Navigation Organizer for Maps, Activities & Destinations</em>
|
||||
<em>Your Trips. Your Plan.</em>
|
||||
</p>
|
||||
|
||||
<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/nomad"><img src="https://img.shields.io/docker/pulls/mauriceboe/nomad" alt="Docker Pulls" /></a>
|
||||
<a href="https://github.com/mauriceboe/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></a>
|
||||
<a href="https://github.com/mauriceboe/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" alt="Last Commit" /></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>
|
||||
|
||||
<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 NOMAD without installing. Resets hourly.
|
||||
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
<details>
|
||||
<summary>More Screenshots</summary>
|
||||
@@ -44,13 +45,16 @@
|
||||
- **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, hotels, restaurants with status, confirmation numbers, and file attachments
|
||||
- **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** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
||||
- **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 NOMAD branding
|
||||
- **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
|
||||
@@ -61,19 +65,22 @@
|
||||
### 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, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
||||
- **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 and German (i18n)
|
||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
||||
- **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
|
||||
|
||||
@@ -84,7 +91,7 @@
|
||||
- **PWA**: vite-plugin-pwa + Workbox
|
||||
- **Real-Time**: WebSocket (`ws`)
|
||||
- **State**: Zustand
|
||||
- **Auth**: JWT + OIDC
|
||||
- **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
|
||||
@@ -92,19 +99,21 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/nomad
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
|
||||
### Install as App (PWA)
|
||||
|
||||
NOMAD works as a Progressive Web App — no App Store needed:
|
||||
TREK works as a Progressive Web App — no App Store needed:
|
||||
|
||||
1. Open your NOMAD instance in the browser (HTTPS required)
|
||||
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. NOMAD launches fullscreen with its own icon, just like a native app
|
||||
4. TREK launches fullscreen with its own icon, just like a native app
|
||||
|
||||
<details>
|
||||
<summary>Docker Compose (recommended for production)</summary>
|
||||
@@ -112,17 +121,56 @@ NOMAD works as a Progressive Web App — no App Store needed:
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/nomad:latest
|
||||
container_name: nomad
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
ports:
|
||||
- "3000:3000"
|
||||
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)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -142,20 +190,32 @@ docker compose pull && docker compose up -d
|
||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/nomad
|
||||
docker rm -f nomad
|
||||
docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||
docker pull mauriceboe/trek
|
||||
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 nomad --format '{{json .Mounts}}'` before removing the container.
|
||||
> **Tip:** Not sure which paths you used? Run `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.
|
||||
|
||||
### Rotating the Encryption Key
|
||||
|
||||
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:
|
||||
|
||||
```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.
|
||||
|
||||
**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".
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
|
||||
> **Important:** NOMAD uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
||||
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
@@ -163,13 +223,13 @@ For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy,
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name nomad.yourdomain.com;
|
||||
server_name trek.yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name nomad.yourdomain.com;
|
||||
server_name trek.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
@@ -204,13 +264,46 @@ server {
|
||||
Caddy handles WebSocket upgrades automatically:
|
||||
|
||||
```
|
||||
nomad.yourdomain.com {
|
||||
trek.yourdomain.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| **Core** | | |
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `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. | — |
|
||||
| **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_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 |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
|
||||
|
||||
## Optional API Keys
|
||||
|
||||
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.
|
||||
@@ -220,20 +313,21 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
|
||||
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 NOMAD: Admin Panel → Settings → Google Maps
|
||||
4. In TREK: Admin Panel → Settings → Google Maps
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/NOMAD.git
|
||||
cd NOMAD
|
||||
docker build -t nomad .
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
docker build -t trek .
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+1
-1
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`).
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||
|
||||
Third-party dependencies are monitored via GitHub Dependabot.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 0.1.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "latest"
|
||||
@@ -0,0 +1,36 @@
|
||||
# TREK Helm Chart
|
||||
|
||||
This is a minimal Helm chart for deploying the TREK app.
|
||||
|
||||
## Features
|
||||
- Deploys the TREK container
|
||||
- Exposes port 3000 via Service
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=yourdomain.com
|
||||
```
|
||||
|
||||
See `values.yaml` for more options.
|
||||
|
||||
## Files
|
||||
- `Chart.yaml` — chart metadata
|
||||
- `values.yaml` — configuration values
|
||||
- `templates/` — Kubernetes manifests
|
||||
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- 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.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
@@ -0,0 +1,23 @@
|
||||
1. ENCRYPTION_KEY handling:
|
||||
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
|
||||
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
|
||||
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
|
||||
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
|
||||
- If left empty, the server resolves the key automatically: existing installs fall back to
|
||||
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
|
||||
auto-generate a key persisted to the data PVC.
|
||||
|
||||
2. JWT_SECRET is managed entirely by the server:
|
||||
- Auto-generated on first start and persisted to the data PVC (data/.jwt_secret).
|
||||
- Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret).
|
||||
- No Helm configuration needed or supported.
|
||||
|
||||
3. Example usage:
|
||||
- Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key`
|
||||
- Generate a random key at install: `--set generateEncryptionKey=true`
|
||||
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
||||
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
|
||||
|
||||
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
|
||||
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
|
||||
@@ -0,0 +1,18 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "trek.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "trek.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,63 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
data:
|
||||
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
|
||||
PORT: {{ .Values.env.PORT | quote }}
|
||||
{{- if .Values.env.TZ }}
|
||||
TZ: {{ .Values.env.TZ | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.LOG_LEVEL }}
|
||||
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.APP_URL }}
|
||||
APP_URL: {{ .Values.env.APP_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.FORCE_HTTPS }}
|
||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TRUST_PROXY }}
|
||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ISSUER }}
|
||||
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_CLIENT_ID }}
|
||||
OIDC_CLIENT_ID: {{ .Values.env.OIDC_CLIENT_ID | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_DISPLAY_NAME }}
|
||||
OIDC_DISPLAY_NAME: {{ .Values.env.OIDC_DISPLAY_NAME | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ONLY }}
|
||||
OIDC_ONLY: {{ .Values.env.OIDC_ONLY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ADMIN_CLAIM }}
|
||||
OIDC_ADMIN_CLAIM: {{ .Values.env.OIDC_ADMIN_CLAIM | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ADMIN_VALUE }}
|
||||
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_SCOPE }}
|
||||
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_DISCOVERY_URL }}
|
||||
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.DEMO_MODE }}
|
||||
DEMO_MODE: {{ .Values.env.DEMO_MODE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,89 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ include "trek.name" . }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- range .Values.imagePullSecrets }}
|
||||
- name: {{ .name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "trek.fullname" . }}-config
|
||||
env:
|
||||
- name: ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
|
||||
optional: true
|
||||
- name: ADMIN_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: ADMIN_EMAIL
|
||||
optional: true
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: ADMIN_PASSWORD
|
||||
optional: true
|
||||
- name: OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-data
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-uploads
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ . }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "trek.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{- if .Values.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-data
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-uploads
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,47 @@
|
||||
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-secret
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
|
||||
{{- if .Values.secretEnv.ADMIN_EMAIL }}
|
||||
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
|
||||
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ $secretName }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{- if and $existingSecret $existingSecret.data }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
|
||||
{{- else }}
|
||||
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.ADMIN_EMAIL }}
|
||||
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: {{ include "trek.name" . }}
|
||||
@@ -0,0 +1,109 @@
|
||||
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
# - name: my-registry-secret
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
|
||||
env:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
# TZ: "UTC"
|
||||
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||
# LOG_LEVEL: "info"
|
||||
# "info" = concise user actions, "debug" = verbose details.
|
||||
# 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.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
|
||||
# TRUST_PROXY: "1"
|
||||
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||
# OIDC_ISSUER: ""
|
||||
# OpenID Connect provider URL.
|
||||
# OIDC_CLIENT_ID: ""
|
||||
# OIDC client ID.
|
||||
# OIDC_DISPLAY_NAME: "SSO"
|
||||
# Label shown on the SSO login button.
|
||||
# OIDC_ONLY: "false"
|
||||
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
|
||||
# OIDC_ADMIN_CLAIM: ""
|
||||
# OIDC claim used to identify admin users.
|
||||
# OIDC_ADMIN_VALUE: ""
|
||||
# Value of the OIDC claim that grants admin role.
|
||||
# OIDC_SCOPE: "openid email profile groups"
|
||||
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
|
||||
# OIDC_DISCOVERY_URL: ""
|
||||
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||
# DEMO_MODE: "false"
|
||||
# Enable demo mode (hourly data resets).
|
||||
# MCP_RATE_LIMIT: "60"
|
||||
# Max MCP API requests per user per minute. Defaults to 60.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC,
|
||||
# rotatable via the admin panel) — it is not configured here.
|
||||
secretEnv:
|
||||
# At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.).
|
||||
# Recommended: set to a random 32-byte hex value (openssl rand -hex 32).
|
||||
# If left empty the server resolves the key automatically:
|
||||
# 1. data/.jwt_secret (existing installs — encrypted data stays readable after upgrade)
|
||||
# 2. data/.encryption_key auto-generated on first start (fresh installs)
|
||||
ENCRYPTION_KEY: ""
|
||||
# Initial admin account — only used on first boot when no users exist yet.
|
||||
# If both values are non-empty the admin account is created with these credentials.
|
||||
# If either is empty a random password is generated and printed to the server log.
|
||||
ADMIN_EMAIL: ""
|
||||
ADMIN_PASSWORD: ""
|
||||
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
|
||||
OIDC_CLIENT_SECRET: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
|
||||
existingSecret: ""
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi
|
||||
uploads:
|
||||
size: 1Gi
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
paths:
|
||||
- /
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - chart-example.local
|
||||
+3
-1
@@ -21,7 +21,9 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.6.1",
|
||||
"version": "2.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "2.6.1",
|
||||
"version": "2.9.4",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
@@ -2789,9 +2789,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3693,9 +3693,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4679,9 +4679,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5941,9 +5941,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7181,9 +7181,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8705,9 +8705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.6.2",
|
||||
"version": "2.9.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
+72
-9
@@ -3,7 +3,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
@@ -11,10 +10,13 @@ import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import DemoBanner from './components/Layout/DemoBanner'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -22,8 +24,12 @@ interface ProtectedRouteProps {
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -37,7 +43,17 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
|
||||
if (
|
||||
appRequireMfa &&
|
||||
user &&
|
||||
!user.mfa_enabled &&
|
||||
location.pathname !== '/settings'
|
||||
) {
|
||||
return <Navigate to="/settings?mfa=required" replace />
|
||||
}
|
||||
|
||||
if (adminRequired && user && user.role !== 'admin') {
|
||||
@@ -62,28 +78,66 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
if (!location.pathname.startsWith('/shared/')) {
|
||||
loadUser()
|
||||
}
|
||||
authApi.getAppConfig().then((config: { demo_mode?: boolean; has_maps_key?: boolean }) => {
|
||||
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> }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
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?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useInAppNotificationListener()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
useEffect(() => {
|
||||
// Shared page always forces light mode
|
||||
if (isSharedPage) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', '#ffffff')
|
||||
return
|
||||
}
|
||||
|
||||
const mode = settings.dark_mode
|
||||
const applyDark = (isDark: boolean) => {
|
||||
document.documentElement.classList.toggle('dark', isDark)
|
||||
@@ -99,7 +153,7 @@ export default function App() {
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode])
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -107,7 +161,8 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
@@ -164,6 +219,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<InAppNotificationsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</TranslationProvider>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export async function getAuthUrl(url: string, purpose: 'download'): Promise<string> {
|
||||
if (!url) return url
|
||||
try {
|
||||
const resp = await fetch('/api/auth/resource-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ purpose }),
|
||||
})
|
||||
if (!resp.ok) return url
|
||||
const { token } = await resp.json()
|
||||
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
|
||||
|
||||
const MAX_CONCURRENT = 6
|
||||
let active = 0
|
||||
const queue: Array<() => void> = []
|
||||
|
||||
function dequeue() {
|
||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||
active++
|
||||
queue.shift()!()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearImageQueue() {
|
||||
queue.length = 0
|
||||
}
|
||||
|
||||
export async function fetchImageAsBlob(url: string): Promise<string> {
|
||||
if (!url) return ''
|
||||
return new Promise<string>((resolve) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) { resolve(''); return }
|
||||
const blob = await resp.blob()
|
||||
resolve(URL.createObjectURL(blob))
|
||||
} catch {
|
||||
resolve('')
|
||||
} finally {
|
||||
active--
|
||||
dequeue()
|
||||
}
|
||||
}
|
||||
if (active < MAX_CONCURRENT) {
|
||||
active++
|
||||
run()
|
||||
} else {
|
||||
queue.push(run)
|
||||
}
|
||||
})
|
||||
}
|
||||
+118
-13
@@ -3,18 +3,15 @@ import { getSocketId } from './websocket'
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token and socket ID
|
||||
// Request interceptor - add socket ID
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
@@ -28,19 +25,31 @@ apiClient.interceptors.request.use(
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth_token')
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
||||
window.location.href = '/login'
|
||||
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
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
||||
@@ -56,6 +65,11 @@ export const authApi = {
|
||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
mcpTokens: {
|
||||
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export const tripsApi = {
|
||||
@@ -70,6 +84,7 @@ export const tripsApi = {
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
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),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
@@ -86,6 +101,12 @@ 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)
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, 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),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -103,9 +124,29 @@ export const assignmentsApi = {
|
||||
export const packingApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const todoApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
@@ -134,7 +175,33 @@ export const adminApi = {
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
||||
updatePackingTemplate: (id: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${id}`, data).then(r => r.data),
|
||||
deletePackingTemplate: (id: number) => apiClient.delete(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||
addTemplateCategory: (templateId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories`, data).then(r => r.data),
|
||||
updateTemplateCategory: (templateId: number, catId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/categories/${catId}`, data).then(r => r.data),
|
||||
deleteTemplateCategory: (templateId: number, catId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/categories/${catId}`).then(r => r.data),
|
||||
addTemplateItem: (templateId: number, catId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories/${catId}/items`, data).then(r => r.data),
|
||||
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
|
||||
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
|
||||
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
|
||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -146,6 +213,7 @@ export const mapsApi = {
|
||||
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 budgetApi = {
|
||||
@@ -156,6 +224,7 @@ export const budgetApi = {
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
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),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
@@ -169,6 +238,9 @@ export const filesApi = {
|
||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
@@ -176,6 +248,7 @@ export const reservationsApi = {
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
@@ -226,9 +299,8 @@ export const backupApi = {
|
||||
list: () => apiClient.get('/backup/list').then(r => r.data),
|
||||
create: () => apiClient.post('/backup/create').then(r => r.data),
|
||||
download: async (filename: string): Promise<void> => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const res = await fetch(`/api/backup/download/${filename}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
@@ -250,4 +322,37 @@ export const backupApi = {
|
||||
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
||||
}
|
||||
|
||||
export const shareApi = {
|
||||
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
|
||||
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
markAllRead: () =>
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
|
||||
+42
-12
@@ -9,9 +9,10 @@ let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set<WebSocketListener>()
|
||||
const activeTrips = new Set<string>()
|
||||
let currentToken: string | null = null
|
||||
let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
let connecting = false
|
||||
|
||||
export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
@@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
function getWsUrl(token: string): string {
|
||||
function getWsUrl(wsToken: string): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${token}`
|
||||
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||
}
|
||||
|
||||
async function fetchWsToken(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/ws-token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (resp.status === 401) {
|
||||
// Session expired — stop reconnecting
|
||||
shouldReconnect = false
|
||||
return null
|
||||
}
|
||||
if (!resp.ok) return null
|
||||
const { token } = await resp.json()
|
||||
return token as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent): void {
|
||||
@@ -45,19 +65,29 @@ function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (currentToken) {
|
||||
connectInternal(currentToken, true)
|
||||
if (shouldReconnect) {
|
||||
connectInternal(true)
|
||||
}
|
||||
}, reconnectDelay)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
function connectInternal(token: string, _isReconnect = false): void {
|
||||
async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
if (connecting) return
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = getWsUrl(token)
|
||||
connecting = true
|
||||
const wsToken = await fetchWsToken()
|
||||
connecting = false
|
||||
|
||||
if (!wsToken) {
|
||||
if (shouldReconnect) scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
const url = getWsUrl(wsToken)
|
||||
socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
@@ -82,7 +112,7 @@ function connectInternal(token: string, _isReconnect = false): void {
|
||||
|
||||
socket.onclose = () => {
|
||||
socket = null
|
||||
if (currentToken) {
|
||||
if (shouldReconnect) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
@@ -92,18 +122,18 @@ function connectInternal(token: string, _isReconnect = false): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(token: string): void {
|
||||
currentToken = token
|
||||
export function connect(): void {
|
||||
shouldReconnect = true
|
||||
reconnectDelay = 1000
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
connectInternal(token, false)
|
||||
connectInternal(false)
|
||||
}
|
||||
|
||||
export function disconnect(): void {
|
||||
currentToken = null
|
||||
shouldReconnect = false
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
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 } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -14,7 +15,17 @@ interface Addon {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
toggle: () => Promise<void>
|
||||
}
|
||||
|
||||
interface AddonIconProps {
|
||||
@@ -27,12 +38,13 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
export default function AddonManager() {
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => 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)
|
||||
const toast = useToast()
|
||||
const [addons, setAddons] = useState([])
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState<Addon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,13 +63,13 @@ export default function AddonManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (addon) => {
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled
|
||||
// Optimistic update
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
try {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
window.dispatchEvent(new Event('addons-changed'))
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
@@ -66,8 +78,44 @@ export default function AddonManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'photo_provider'
|
||||
}
|
||||
|
||||
const isPhotosAddon = (addon: Addon) => {
|
||||
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
|
||||
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
}
|
||||
|
||||
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||
const enableProvider = !providerAddon.enabled
|
||||
const prev = addons
|
||||
|
||||
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||
|
||||
try {
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch {
|
||||
setAddons(prev)
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
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,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
}))
|
||||
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -104,7 +152,63 @@ export default function AddonManager() {
|
||||
</span>
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
<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>
|
||||
)}
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div 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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={onToggleBagTracking}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -123,6 +227,21 @@ export default function AddonManager() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -132,12 +251,30 @@ export default function AddonManager() {
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addonId: string) => void
|
||||
onToggle: (addon: Addon) => void
|
||||
t: (key: string) => string
|
||||
statusOverride?: boolean
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`
|
||||
const translatedName = t(nameKey)
|
||||
const translatedDescription = t(descKey)
|
||||
|
||||
return {
|
||||
name: translatedName !== nameKey ? translatedName : addon.name,
|
||||
description: translatedDescription !== descKey ? translatedDescription : addon.description,
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
const displayName = nameOverride || label.name
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
@@ -148,41 +285,40 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{addon.description}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{!hideToggle && (
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface AdminMcpToken {
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
user_id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
adminApi.mcpTokens()
|
||||
.then(d => setTokens(d.tokens || []))
|
||||
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deleteMcpToken(id)
|
||||
setTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setDeleteConfirmId(null)
|
||||
toast.success(t('admin.mcpTokens.deleteSuccess'))
|
||||
} catch {
|
||||
toast.error(t('admin.mcpTokens.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { RefreshCw, ClipboardList } from 'lucide-react'
|
||||
|
||||
interface AuditEntry {
|
||||
id: number
|
||||
created_at: string
|
||||
user_id: number | null
|
||||
username: string | null
|
||||
user_email: string | null
|
||||
action: string
|
||||
resource: string | null
|
||||
details: Record<string, unknown> | null
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
interface AuditLogPanelProps {
|
||||
serverTimezone?: string
|
||||
}
|
||||
|
||||
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const limit = 100
|
||||
|
||||
const loadFirstPage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
|
||||
entries: AuditEntry[]
|
||||
total: number
|
||||
}
|
||||
setEntries(data.entries || [])
|
||||
setTotal(data.total ?? 0)
|
||||
setOffset(0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
setTotal(0)
|
||||
setOffset(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const nextOffset = offset + limit
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
|
||||
entries: AuditEntry[]
|
||||
total: number
|
||||
}
|
||||
setEntries((prev) => [...prev, ...(data.entries || [])])
|
||||
setTotal(data.total ?? 0)
|
||||
setOffset(nextOffset)
|
||||
} catch {
|
||||
/* keep existing */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [offset])
|
||||
|
||||
useEffect(() => {
|
||||
loadFirstPage()
|
||||
}, [loadFirstPage])
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: serverTimezone || undefined,
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
const fmtDetails = (d: Record<string, unknown> | null) => {
|
||||
if (!d || Object.keys(d).length === 0) return '—'
|
||||
try {
|
||||
return JSON.stringify(d)
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
const userLabel = (e: AuditEntry) => {
|
||||
if (e.username) return e.username
|
||||
if (e.user_email) return e.user_email
|
||||
if (e.user_id != null) return `#${e.user_id}`
|
||||
return '—'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
||||
<ClipboardList size={20} />
|
||||
{t('admin.tabs.audit')}
|
||||
</h2>
|
||||
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.audit.showing', { count: entries.length, total })}
|
||||
</p>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length < total && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
@@ -21,19 +23,35 @@ const KEEP_OPTIONS = [
|
||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||
]
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||
]
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||
|
||||
export default function BackupPanel() {
|
||||
const [backups, setBackups] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [restoringFile, setRestoringFile] = useState(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||
const [serverTimezone, setServerTimezone] = useState('')
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||
const fileInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const loadBackups = async () => {
|
||||
setIsLoading(true)
|
||||
@@ -51,6 +69,7 @@ export default function BackupPanel() {
|
||||
try {
|
||||
const data = await backupApi.getAutoSettings()
|
||||
setAutoSettings(data.settings)
|
||||
if (data.timezone) setServerTimezone(data.timezone)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -147,10 +166,12 @@ export default function BackupPanel() {
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString(locale, {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
if (serverTimezone) opts.timeZone = serverTimezone
|
||||
return new Date(dateStr).toLocaleString(locale, opts)
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
@@ -303,9 +324,11 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
@@ -331,6 +354,68 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hour picker (for daily, weekly, monthly) */}
|
||||
{autoSettings.interval !== 'hourly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
label = `${h12}:00 ${period}`
|
||||
} else {
|
||||
label = `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
return { value: String(h), label }
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day of week (for weekly) */}
|
||||
{autoSettings.interval === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoSettings.day_of_week === opt.value
|
||||
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day of month (for monthly) */}
|
||||
{autoSettings.interval === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keep duration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
|
||||
@@ -198,7 +198,6 @@ export default function CategoryManager() {
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||
<span className="sm:hidden">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import {
|
||||
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
interface AppUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function DevNotificationsPanel(): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const [sending, setSending] = useState<string | null>(null)
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
|
||||
const [users, setUsers] = useState<AppUser[]>([])
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
tripsApi.list().then(data => {
|
||||
const list = (data.trips || data || []) as Trip[]
|
||||
setTrips(list)
|
||||
if (list.length > 0) setSelectedTripId(list[0].id)
|
||||
}).catch(() => {})
|
||||
adminApi.users().then(data => {
|
||||
const list = (data.users || data || []) as AppUser[]
|
||||
setUsers(list)
|
||||
if (list.length > 0) setSelectedUserId(list[0].id)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||
setSending(label)
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
toast.success(`Sent: ${label}`)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed')
|
||||
} finally {
|
||||
setSending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTrip = trips.find(t => t.id === selectedTripId)
|
||||
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||
const username = user?.username || 'Admin'
|
||||
const tripTitle = selectedTrip?.title || 'Test Trip'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id, label, sub, icon: Icon, color, onClick,
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}20`, color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
const UserSelector = () => (
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||
DEV ONLY
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Notification Testing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||
onClick={() => fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||
onClick={() => fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||
onClick={() => fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||
onClick={() => fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||
onClick={() => selectedTripId && fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||
onClick={() => selectedTripId && fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||
onClick={() => selectedTripId && fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
color="#06b6d4"
|
||||
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id={`vacay_invite-${selectedUserId}`}
|
||||
label="vacay_invite"
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
color="#f97316"
|
||||
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, planId: '1' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||
onClick={() => fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
const REPO = 'mauriceboe/NOMAD'
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
export default function GitHubPanel() {
|
||||
@@ -18,8 +18,8 @@ export default function GitHubPanel() {
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/auth/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
const data = res.data
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
const data = Array.isArray(res.data) ? res.data : []
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
} catch (err: unknown) {
|
||||
@@ -46,7 +46,7 @@ export default function GitHubPanel() {
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
@@ -72,11 +72,15 @@ export default function GitHubPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const inlineFormat = (text) => {
|
||||
return text
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -115,7 +119,7 @@ export default function GitHubPanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
@@ -130,7 +134,7 @@ export default function GitHubPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{language === 'de' ? 'Hilft mir, TREK weiterzuentwickeln' : 'Helps me keep building TREK'}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
@@ -148,7 +152,82 @@ export default function GitHubPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{language === 'de' ? 'Hilft mir, TREK weiterzuentwickeln' : 'Helps me keep building TREK'}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nSdKaXgN"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
|
||||
|
||||
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
|
||||
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
|
||||
interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
|
||||
|
||||
export default function PackingTemplateManager() {
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
|
||||
// Expanded template state
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [items, setItems] = useState<TemplateItem[]>([])
|
||||
|
||||
// Editing states
|
||||
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
|
||||
const [editTemplateName, setEditTemplateName] = useState('')
|
||||
const [editingCatId, setEditingCatId] = useState<number | null>(null)
|
||||
const [editCatName, setEditCatName] = useState('')
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||
const [editItemName, setEditItemName] = useState('')
|
||||
|
||||
// Adding states
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
|
||||
const [newItemName, setNewItemName] = useState('')
|
||||
const addItemRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => { loadTemplates() }, [])
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await adminApi.packingTemplates()
|
||||
setTemplates(data.templates || [])
|
||||
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||
finally { setIsLoading(false) }
|
||||
}
|
||||
|
||||
const toggleExpand = async (id: number) => {
|
||||
if (expandedId === id) { setExpandedId(null); return }
|
||||
setExpandedId(id)
|
||||
setAddingCategory(false)
|
||||
setAddingItemToCatId(null)
|
||||
try {
|
||||
const data = await adminApi.getPackingTemplate(id)
|
||||
setCategories(data.categories || [])
|
||||
setItems(data.items || [])
|
||||
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||
}
|
||||
|
||||
// Template CRUD
|
||||
const handleCreateTemplate = async () => {
|
||||
if (!createName.trim()) return
|
||||
try {
|
||||
const data = await adminApi.createPackingTemplate({ name: createName.trim() })
|
||||
setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
|
||||
setCreateName(''); setShowCreate(false)
|
||||
setExpandedId(data.template.id); setCategories([]); setItems([])
|
||||
toast.success(t('admin.packingTemplates.created'))
|
||||
} catch { toast.error(t('admin.packingTemplates.createError')) }
|
||||
}
|
||||
|
||||
const handleDeleteTemplate = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deletePackingTemplate(id)
|
||||
setTemplates(prev => prev.filter(t => t.id !== id))
|
||||
if (expandedId === id) setExpandedId(null)
|
||||
toast.success(t('admin.packingTemplates.deleted'))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
}
|
||||
|
||||
const handleRenameTemplate = async (id: number) => {
|
||||
if (!editTemplateName.trim()) { setEditingTemplate(null); return }
|
||||
try {
|
||||
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
|
||||
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
|
||||
setEditingTemplate(null)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
// Category CRUD
|
||||
const handleAddCategory = async () => {
|
||||
if (!newCatName.trim() || !expandedId) return
|
||||
try {
|
||||
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
|
||||
setCategories(prev => [...prev, data.category])
|
||||
setNewCatName(''); setAddingCategory(false)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
const handleRenameCategory = async (catId: number) => {
|
||||
if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
|
||||
try {
|
||||
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
|
||||
setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
|
||||
setEditingCatId(null)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
const handleDeleteCategory = async (catId: number) => {
|
||||
if (!expandedId) return
|
||||
try {
|
||||
await adminApi.deleteTemplateCategory(expandedId, catId)
|
||||
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||
setItems(prev => prev.filter(i => i.category_id !== catId))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
}
|
||||
|
||||
// Item CRUD
|
||||
const handleAddItem = async (catId: number) => {
|
||||
if (!newItemName.trim() || !expandedId) return
|
||||
try {
|
||||
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||
setItems(prev => [...prev, data.item])
|
||||
setNewItemName('')
|
||||
setTimeout(() => addItemRef.current?.focus(), 30)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
const handleRenameItem = async (itemId: number) => {
|
||||
if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
|
||||
try {
|
||||
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
|
||||
setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
|
||||
setEditingItemId(null)
|
||||
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||
}
|
||||
|
||||
const handleDeleteItem = async (itemId: number) => {
|
||||
if (!expandedId) return
|
||||
try {
|
||||
await adminApi.deleteTemplateItem(expandedId, itemId)
|
||||
setItems(prev => prev.filter(i => i.id !== itemId))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
}
|
||||
|
||||
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||
const btnIcon = 'p-1.5 rounded-lg transition-colors'
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create template */}
|
||||
{showCreate && (
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template list */}
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center"><div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" /></div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{templates.map(tmpl => (
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
{editingTemplate === tmpl.id ? (
|
||||
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||
) : (
|
||||
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
</span>
|
||||
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||
<button onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expandedId === tmpl.id && (
|
||||
<div className="px-5 pb-4 ml-8 space-y-3">
|
||||
{categories.map(cat => {
|
||||
const catItems = items.filter(i => i.category_id === cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
{/* Category header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
|
||||
{editingCatId === cat.id ? (
|
||||
<>
|
||||
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
|
||||
onBlur={() => handleRenameCategory(cat.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
|
||||
</>
|
||||
) : (
|
||||
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||
<button onClick={() => handleDeleteCategory(cat.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{catItems.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
|
||||
{editingItemId === item.id ? (
|
||||
<>
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||
<button onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add item inline */}
|
||||
{addingItemToCatId === cat.id && (
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add category button */}
|
||||
{addingCategory ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setAddingCategory(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
interface PermissionEntry {
|
||||
key: string
|
||||
level: PermissionLevel
|
||||
defaultLevel: PermissionLevel
|
||||
allowedLevels: PermissionLevel[]
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
admin: 'perm.level.admin',
|
||||
trip_owner: 'perm.level.tripOwner',
|
||||
trip_member: 'perm.level.tripMember',
|
||||
everybody: 'perm.level.everybody',
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
|
||||
{ id: 'members', keys: ['member_manage'] },
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
]
|
||||
|
||||
export default function PermissionsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [entries, setEntries] = useState<PermissionEntry[]>([])
|
||||
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions()
|
||||
}, [])
|
||||
|
||||
const loadPermissions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getPermissions()
|
||||
setEntries(data.permissions)
|
||||
const vals: Record<string, PermissionLevel> = {}
|
||||
for (const p of data.permissions) vals[p.key] = p.level
|
||||
setValues(vals)
|
||||
setDirty(false)
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (key: string, level: PermissionLevel) => {
|
||||
setValues(prev => ({ ...prev, [key]: level }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await adminApi.updatePermissions(values)
|
||||
if (data.permissions) {
|
||||
usePermissionsStore.getState().setPermissions(data.permissions)
|
||||
}
|
||||
setDirty(false)
|
||||
toast.success(t('perm.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults: Record<string, PermissionLevel> = {}
|
||||
for (const p of entries) defaults[p.key] = p.defaultLevel
|
||||
setValues(defaults)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('perm.resetDefaults')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="px-6 py-4">
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{t(`perm.cat.${cat.id}`)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{cat.keys.map(key => {
|
||||
const entry = entryMap.get(key)
|
||||
if (!entry) return null
|
||||
const currentLevel = values[key] || entry.defaultLevel
|
||||
const isDefault = currentLevel === entry.defaultLevel
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDefault && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
{t('perm.customized')}
|
||||
</span>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={currentLevel}
|
||||
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||
options={entry.allowedLevels.map(l => ({
|
||||
value: l,
|
||||
label: t(LEVEL_LABELS[l] || l),
|
||||
}))}
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
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 } from 'lucide-react'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
|
||||
@@ -29,8 +31,23 @@ interface PerPersonSummaryEntry {
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
||||
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
|
||||
const CURRENCIES = [
|
||||
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
const SYMBOLS = {
|
||||
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
const fmtNum = (v, locale, cur) => {
|
||||
@@ -44,7 +61,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
|
||||
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
||||
|
||||
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
|
||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value ?? '')
|
||||
const inputRef = useRef(null)
|
||||
@@ -71,12 +88,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
: (value || '')
|
||||
|
||||
return (
|
||||
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
||||
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
)
|
||||
@@ -84,7 +101,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
|
||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -94,12 +111,13 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [persons, setPersons] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [expenseDate, setExpenseDate] = useState('')
|
||||
const nameRef = useRef(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
@@ -117,15 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
@@ -145,9 +168,11 @@ interface ChipWithTooltipProps {
|
||||
label: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
paid?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
@@ -160,13 +185,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
@@ -177,11 +208,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
{paid && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}>Paid</span>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
@@ -194,10 +233,12 @@ interface BudgetMemberChipsProps {
|
||||
members?: BudgetMember[]
|
||||
tripMembers?: TripMember[]
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||
compact?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
@@ -237,16 +278,21 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize} />
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
)}
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
@@ -376,15 +422,25 @@ interface BudgetPanelProps {
|
||||
}
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
const canEdit = can('budget_edit', trip)
|
||||
|
||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
|
||||
// Load settlement data whenever budget items change
|
||||
useEffect(() => {
|
||||
if (!hasMultipleMembers) return
|
||||
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||
}, [tripId, budgetItems, hasMultipleMembers])
|
||||
|
||||
const setCurrency = (cur) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
}
|
||||
@@ -427,6 +483,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
setNewCategoryName('')
|
||||
}
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||
const d = currencyDecimals(currency)
|
||||
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
|
||||
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
|
||||
for (const cat of categoryNames) {
|
||||
for (const item of (grouped[cat] || [])) {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
rows.push([
|
||||
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
|
||||
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
|
||||
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
|
||||
esc(item.note || ''),
|
||||
].join(sep))
|
||||
}
|
||||
}
|
||||
|
||||
const bom = '\uFEFF'
|
||||
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim()
|
||||
a.download = `budget-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||
|
||||
@@ -439,16 +530,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -461,6 +554,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<Calculator size={20} color="var(--text-primary)" />
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||
</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' }}>
|
||||
@@ -475,7 +572,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<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 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
{editingCat?.name === cat ? (
|
||||
{canEdit && editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
@@ -487,21 +584,25 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -509,14 +610,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||
<th style={{ ...th, width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -531,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<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 }}>
|
||||
@@ -539,13 +641,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
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>
|
||||
)}
|
||||
</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')} />
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
@@ -553,29 +657,42 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||
{canEdit ? (
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -584,29 +701,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{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 style={{
|
||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||
@@ -621,13 +741,98 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||
</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} />
|
||||
)}
|
||||
|
||||
{/* 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 }}>
|
||||
<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,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||
<span style={{ display: 'flex', cursor: 'help' }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Info size={11} strokeWidth={2} />
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
}}>
|
||||
{t('budget.settlementInfo')}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 10, 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' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
</div>
|
||||
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
||||
</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 }}>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (
|
||||
@@ -641,27 +846,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<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: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{pieSegments.map(seg => (
|
||||
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'
|
||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
@@ -353,6 +355,9 @@ interface CollabChatProps {
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(msg.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onTouchEnd={e => {
|
||||
const now = Date.now()
|
||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||
if (now - lastTap < 300) {
|
||||
if (now - lastTap < 300 && canEdit) {
|
||||
e.preventDefault()
|
||||
const touch = e.changedTouches?.[0]
|
||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||
@@ -692,7 +697,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -703,8 +708,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && (
|
||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
disabled={!canEdit}
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||
maxHeight: 100, overflowY: 'hidden',
|
||||
opacity: canEdit ? 1 : 0.5,
|
||||
}}
|
||||
placeholder={t('collab.chat.placeholder')}
|
||||
value={text}
|
||||
@@ -805,15 +814,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,8 +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 { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
|
||||
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 { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
@@ -94,22 +97,37 @@ interface FilePreviewPortalProps {
|
||||
}
|
||||
|
||||
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const [authUrl, setAuthUrl] = useState('')
|
||||
const rawUrl = file?.url || ''
|
||||
useEffect(() => {
|
||||
setAuthUrl('')
|
||||
if (!rawUrl) return
|
||||
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||
}, [rawUrl])
|
||||
|
||||
if (!file) return null
|
||||
const url = file.url || `/uploads/${file.filename}`
|
||||
const isImage = file.mime_type?.startsWith('image/')
|
||||
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')
|
||||
}
|
||||
|
||||
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}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
{authUrl
|
||||
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
: <Loader2 size={32} className="animate-spin" style={{ color: 'rgba(255,255,255,0.5)' }} />
|
||||
}
|
||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,19 +138,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={`${url}#view=FitH`} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,6 +160,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||
}
|
||||
|
||||
const NOTE_COLORS = [
|
||||
{ value: '#6366f1', label: 'Indigo' },
|
||||
{ value: '#ef4444', label: 'Red' },
|
||||
@@ -216,7 +242,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
||||
interface NoteFormModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
||||
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
|
||||
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||
existingCategories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
getCategoryColor: (category: string) => string
|
||||
@@ -226,6 +252,9 @@ interface NoteFormModalProps {
|
||||
}
|
||||
|
||||
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canUploadFiles = can('file_upload', tripObj)
|
||||
const isEdit = !!note
|
||||
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
||||
|
||||
@@ -298,6 +327,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPaste={e => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -450,18 +480,18 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
</div>
|
||||
|
||||
{/* File attachments */}
|
||||
<div>
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
|
||||
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Existing attachments (edit mode) */}
|
||||
{existingAttachments.map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
return (
|
||||
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{isImage && <img src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
|
||||
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
|
||||
<X size={10} />
|
||||
@@ -478,12 +508,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label htmlFor="note-file-input"
|
||||
<button type="button" onClick={() => fileRef.current?.click()}
|
||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
@@ -689,6 +719,7 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
|
||||
interface NoteCardProps {
|
||||
note: CollabNote
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => Promise<void>
|
||||
onEdit: (note: CollabNote) => void
|
||||
@@ -699,7 +730,7 @@ interface NoteCardProps {
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||
@@ -760,24 +791,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
||||
<Maximize2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||
</button>
|
||||
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
</button>}
|
||||
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</button>}
|
||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||
{/* Author avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||
@@ -838,7 +869,7 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return isImage ? (
|
||||
<img key={a.id} src={a.url} alt={a.original_name}
|
||||
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => onPreviewFile?.(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
@@ -879,6 +910,9 @@ interface CollabNotesProps {
|
||||
|
||||
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
const [notes, setNotes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
@@ -964,7 +998,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {}
|
||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
|
||||
}
|
||||
// Reload note with attachments
|
||||
const fresh = await collabApi.getNotes(tripId)
|
||||
@@ -1124,17 +1158,17 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
<button onClick={() => setShowNewModal(true)}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => setShowNewModal(true)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
<Plus size={12} />
|
||||
{t('collab.notes.new')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1252,6 +1286,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
key={note.id}
|
||||
note={note}
|
||||
currentUser={currentUser}
|
||||
canEdit={canEdit}
|
||||
onUpdate={handleUpdateNote}
|
||||
onDelete={handleDeleteNote}
|
||||
onEdit={setEditingNote}
|
||||
@@ -1303,12 +1338,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</button>}
|
||||
<button onClick={() => setViewingNote(null)}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
@@ -1319,6 +1354,41 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
</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>
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(viewingNote.attachments || []).map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return (
|
||||
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
|
||||
{isImage ? (
|
||||
<AuthedImg src={a.url} alt={a.original_name}
|
||||
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => setPreviewFile(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<div title={a.original_name} onClick={() => setPreviewFile(a)}
|
||||
style={{
|
||||
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -1327,6 +1397,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
|
||||
{showNewModal && (
|
||||
<NoteFormModal
|
||||
note={null}
|
||||
tripId={tripId}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
onSubmit={handleCreateNote}
|
||||
existingCategories={categories}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import ReactDOM from 'react-dom'
|
||||
import type { User } from '../../types'
|
||||
|
||||
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
interface PollCardProps {
|
||||
poll: Poll
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||
onClose: (pollId: number) => Promise<void>
|
||||
onDelete: (pollId: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
const total = totalVotes(poll)
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -337,6 +342,9 @@ interface CollabPollsProps {
|
||||
|
||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
const [polls, setPolls] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activePolls.length > 0 && activePolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
</div>
|
||||
)}
|
||||
{closedPolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) {
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
|
||||
@@ -4,17 +4,23 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
const CURRENCIES = [
|
||||
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
|
||||
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
|
||||
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
|
||||
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
|
||||
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
|
||||
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||
]
|
||||
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||
|
||||
export default function CurrencyWidget() {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||
const [amount, setAmount] = useState('100')
|
||||
@@ -40,7 +46,7 @@ export default function CurrencyWidget() {
|
||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||
const formatNumber = (num) => {
|
||||
if (!num || num === '—') return '—'
|
||||
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
const result = rawResult
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock, Plus, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const POPULAR_ZONES = [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
@@ -23,9 +24,9 @@ const POPULAR_ZONES = [
|
||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||
]
|
||||
|
||||
function getTime(tz) {
|
||||
function getTime(tz, locale, is12h) {
|
||||
try {
|
||||
return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' })
|
||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
} catch { return '—' }
|
||||
}
|
||||
|
||||
@@ -41,7 +42,8 @@ function getOffset(tz) {
|
||||
}
|
||||
|
||||
export default function TimezoneWidget() {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [zones, setZones] = useState(() => {
|
||||
const saved = localStorage.getItem('dashboard_timezones')
|
||||
return saved ? JSON.parse(saved) : [
|
||||
@@ -51,6 +53,9 @@ export default function TimezoneWidget() {
|
||||
})
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [customLabel, setCustomLabel] = useState('')
|
||||
const [customTz, setCustomTz] = useState('')
|
||||
const [customError, setCustomError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||
@@ -61,6 +66,20 @@ export default function TimezoneWidget() {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||
}, [zones])
|
||||
|
||||
const isValidTz = (tz: string) => {
|
||||
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
||||
}
|
||||
|
||||
const addCustomZone = () => {
|
||||
const tz = customTz.trim()
|
||||
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
||||
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
||||
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
||||
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
||||
setZones([...zones, { label, tz }])
|
||||
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
||||
}
|
||||
|
||||
const addZone = (zone) => {
|
||||
if (!zones.find(z => z.tz === zone.tz)) {
|
||||
setZones([...zones, zone])
|
||||
@@ -70,7 +89,7 @@ export default function TimezoneWidget() {
|
||||
|
||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||
|
||||
const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||
@@ -96,7 +115,7 @@ export default function TimezoneWidget() {
|
||||
{zones.map(z => (
|
||||
<div key={z.tz} className="flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz)}</p>
|
||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||
</div>
|
||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||
@@ -108,7 +127,29 @@ export default function TimezoneWidget() {
|
||||
|
||||
{/* Add zone dropdown */}
|
||||
{showAdd && (
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{/* Custom timezone */}
|
||||
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
||||
<div className="space-y-1.5">
|
||||
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
||||
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
||||
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
||||
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
||||
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
||||
<button onClick={addCustomZone}
|
||||
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{t('dashboard.timezoneCustomAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Popular zones */}
|
||||
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||
<button key={z.tz} onClick={() => addZone(z)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
||||
@@ -116,7 +157,7 @@ export default function TimezoneWidget() {
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<span className="font-medium">{z.label}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz)}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
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 } from 'lucide-react'
|
||||
import { Upload, Trash2, ExternalLink, 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'
|
||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
@@ -33,41 +37,130 @@ function formatDateWithLocale(dateStr, locale) {
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
// Image lightbox
|
||||
// Image lightbox with gallery navigation
|
||||
interface ImageLightboxProps {
|
||||
file: TripFile & { url: string }
|
||||
files: (TripFile & { url: string })[]
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||
function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(initialIndex)
|
||||
const [imgSrc, setImgSrc] = useState('')
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const file = files[index]
|
||||
|
||||
useEffect(() => {
|
||||
setImgSrc('')
|
||||
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||
}, [file?.url])
|
||||
|
||||
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
||||
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') goPrev()
|
||||
if (e.key === 'ArrowRight') goNext()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
if (!file) return null
|
||||
|
||||
const hasPrev = index > 0
|
||||
const hasNext = index < files.length - 1
|
||||
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
|
||||
<button onClick={e => { e.stopPropagation(); onClick() }}
|
||||
style={{
|
||||
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
|
||||
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
|
||||
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
|
||||
onClick={onClose}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
if (touchStart === null) return
|
||||
const diff = e.changedTouches[0].clientX - touchStart
|
||||
if (diff > 60) goPrev()
|
||||
else if (diff < -60) goNext()
|
||||
setTouchStart(null)
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.original_name}
|
||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
{file.original_name}
|
||||
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||
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={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main image + nav */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
{navBtn('left', goPrev, hasPrev)}
|
||||
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
||||
{navBtn('right', goNext, hasNext)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{files.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
{files.map((f, i) => (
|
||||
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
|
||||
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated image — fetches a short-lived download token and renders the image
|
||||
function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
|
||||
}
|
||||
|
||||
// Source badge
|
||||
interface SourceBadgeProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
@@ -148,11 +241,13 @@ interface FileManagerProps {
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [lightboxFile, setLightboxFile] = useState(null)
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||
const [showTrash, setShowTrash] = useState(false)
|
||||
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
||||
const [loadingTrash, setLoadingTrash] = useState(false)
|
||||
const toast = useToast()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const loadTrash = useCallback(async () => {
|
||||
@@ -247,6 +342,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
})
|
||||
|
||||
const handlePaste = useCallback((e) => {
|
||||
if (!can('file_upload', trip)) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const pastedFiles = []
|
||||
@@ -281,6 +377,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
|
||||
const [previewFile, setPreviewFile] = useState(null)
|
||||
const [previewFileUrl, setPreviewFileUrl] = useState('')
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
|
||||
} else {
|
||||
setPreviewFileUrl('')
|
||||
}
|
||||
}, [previewFile?.url])
|
||||
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||
|
||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||
@@ -292,9 +396,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
|
||||
|
||||
const openFile = (file) => {
|
||||
if (isImage(file.mime_type)) {
|
||||
setLightboxFile(file)
|
||||
const idx = imageFiles.findIndex(f => f.id === file.id)
|
||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
}
|
||||
@@ -302,12 +409,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
|
||||
const renderFileRow = (file: TripFile, isTrash = false) => {
|
||||
const FileIcon = getFileIcon(file.mime_type)
|
||||
const linkedPlace = places?.find(p => p.id === file.place_id)
|
||||
const linkedReservation = file.reservation_id
|
||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
||||
: null
|
||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||
|
||||
const allLinkedPlaceIds = new Set<number>()
|
||||
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
||||
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
||||
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
||||
// All linked reservations (primary + file_links)
|
||||
const allLinkedResIds = new Set<number>()
|
||||
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
@@ -321,7 +431,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
>
|
||||
{/* Icon or thumbnail */}
|
||||
<div
|
||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -329,7 +439,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}}
|
||||
>
|
||||
{isImage(file.mime_type)
|
||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: (() => {
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
@@ -350,7 +460,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
)}
|
||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||
<span
|
||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||
>
|
||||
{file.original_name}
|
||||
@@ -365,12 +475,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
|
||||
{linkedPlace && (
|
||||
<SourceBadge icon={MapPin} label={`${t('files.sourcePlan')} · ${linkedPlace.name}`} />
|
||||
)}
|
||||
{linkedReservation && (
|
||||
<SourceBadge icon={Ticket} label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`} />
|
||||
)}
|
||||
{linkedPlaces.map(p => (
|
||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||
))}
|
||||
{linkedReservations.map(r => (
|
||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
))}
|
||||
{file.note_id && (
|
||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||
)}
|
||||
@@ -381,14 +491,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{isTrash ? (
|
||||
<>
|
||||
<button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
</button>}
|
||||
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -396,18 +506,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||
</button>
|
||||
<button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
</button>}
|
||||
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{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} />
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -418,7 +528,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
||||
{/* Lightbox */}
|
||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
||||
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||
|
||||
{/* Assign modal */}
|
||||
{assignFileId && ReactDOM.createPortal(
|
||||
@@ -477,20 +587,45 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
}
|
||||
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||
const placeBtn = (p: Place) => (
|
||||
<button key={p.id} onClick={() => handleAssign(file.id, { place_id: file.place_id === p.id ? null : p.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.place_id === p.id ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = file.place_id === p.id ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{file.place_id === p.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
const placeBtn = (p: Place) => {
|
||||
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||
return (
|
||||
<button key={p.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.place_id === p.id) {
|
||||
await handleAssign(file.id, { place_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
if (!file.place_id) {
|
||||
await handleAssign(file.id, { place_id: p.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const placesSection = places.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -519,20 +654,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.assignBooking')}
|
||||
</div>
|
||||
{reservations.map(r => (
|
||||
<button key={r.id} onClick={() => handleAssign(file.id, { reservation_id: file.reservation_id === r.id ? null : r.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.reservation_id === r.id ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.reservation_id === r.id ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = file.reservation_id === r.id ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{file.reservation_id === r.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
))}
|
||||
{reservations.map(r => {
|
||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||
return (
|
||||
<button key={r.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
||||
if (file.reservation_id === r.id) {
|
||||
await handleAssign(file.id, { reservation_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
// Link: if no primary, set it; otherwise use file_links
|
||||
if (!file.reservation_id) {
|
||||
await handleAssign(file.id, { reservation_id: r.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -565,12 +727,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<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 }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||
<button
|
||||
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||
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')}
|
||||
</a>
|
||||
</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)'}
|
||||
@@ -580,13 +743,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
</div>
|
||||
<object
|
||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
||||
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
||||
<button onClick={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>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
@@ -618,7 +781,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{showTrash ? (
|
||||
/* Trash view */
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
{trashFiles.length > 0 && (
|
||||
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<button onClick={handleEmptyTrash} style={{
|
||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||
@@ -647,7 +810,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
) : (
|
||||
<>
|
||||
{/* Upload zone */}
|
||||
<div
|
||||
{can('file_upload', trip) && <div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
@@ -672,7 +835,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -86,6 +86,134 @@ const texts: Record<string, DemoTexts> = {
|
||||
selfHostLink: 'self-host it',
|
||||
close: 'Got it',
|
||||
},
|
||||
es: {
|
||||
titleBefore: 'Bienvenido a ',
|
||||
titleAfter: '',
|
||||
title: 'Bienvenido a la demo de TREK',
|
||||
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
|
||||
resetIn: 'Próximo reinicio en',
|
||||
minutes: 'minutos',
|
||||
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
|
||||
fullVersionTitle: 'Además, en la versión completa:',
|
||||
features: [
|
||||
'Subida de archivos (fotos, documentos, portadas)',
|
||||
'Gestión de claves API (Google Maps, tiempo)',
|
||||
'Gestión de usuarios y permisos',
|
||||
'Copias de seguridad automáticas',
|
||||
'Gestión de addons (activar/desactivar)',
|
||||
'Inicio de sesión único OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
|
||||
addons: [
|
||||
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
|
||||
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
|
||||
['Equipaje', 'Listas de comprobación para cada viaje'],
|
||||
['Presupuesto', 'Control de gastos con reparto'],
|
||||
['Documentos', 'Adjunta archivos a los viajes'],
|
||||
['Widgets', 'Conversor de divisas y zonas horarias'],
|
||||
],
|
||||
whatIs: '¿Qué es TREK?',
|
||||
whatIsDesc: 'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
|
||||
selfHost: 'Código abierto — ',
|
||||
selfHostLink: 'alójalo tú mismo',
|
||||
close: 'Entendido',
|
||||
},
|
||||
zh: {
|
||||
titleBefore: '欢迎来到 ',
|
||||
titleAfter: '',
|
||||
title: '欢迎来到 TREK 演示版',
|
||||
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
|
||||
resetIn: '下次重置将在',
|
||||
minutes: '分钟后',
|
||||
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
|
||||
fullVersionTitle: '完整版本还包括:',
|
||||
features: [
|
||||
'文件上传(照片、文档、封面)',
|
||||
'API 密钥管理(Google Maps、天气)',
|
||||
'用户和权限管理',
|
||||
'自动备份',
|
||||
'附加组件管理(启用/禁用)',
|
||||
'OIDC / SSO 单点登录',
|
||||
],
|
||||
addonsTitle: '模块化附加组件(完整版本可禁用)',
|
||||
addons: [
|
||||
['Vacay', '带日历、节假日和用户融合的假期规划器'],
|
||||
['Atlas', '带已访问国家和旅行统计的世界地图'],
|
||||
['Packing', '按旅行管理清单'],
|
||||
['Budget', '支持分摊的费用追踪'],
|
||||
['Documents', '将文件附加到旅行'],
|
||||
['Widgets', '货币换算和时区工具'],
|
||||
],
|
||||
whatIs: '什么是 TREK?',
|
||||
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
|
||||
selfHost: '开源项目 - ',
|
||||
selfHostLink: '自行部署',
|
||||
close: '知道了',
|
||||
},
|
||||
'zh-TW': {
|
||||
titleBefore: '歡迎來到 ',
|
||||
titleAfter: '',
|
||||
title: '歡迎來到 TREK 展示版',
|
||||
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
|
||||
resetIn: '下次重設將在',
|
||||
minutes: '分鐘後',
|
||||
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
|
||||
fullVersionTitle: '完整版本還包含:',
|
||||
features: [
|
||||
'檔案上傳(照片、文件、封面)',
|
||||
'API 金鑰管理(Google Maps、天氣)',
|
||||
'使用者與權限管理',
|
||||
'自動備份',
|
||||
'附加元件管理(啟用/停用)',
|
||||
'OIDC / SSO 單一登入',
|
||||
],
|
||||
addonsTitle: '模組化附加元件(完整版本可停用)',
|
||||
addons: [
|
||||
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
|
||||
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
|
||||
['Packing', '依行程管理的檢查清單'],
|
||||
['Budget', '支援分攤的費用追蹤'],
|
||||
['Documents', '將檔案附加到行程'],
|
||||
['Widgets', '貨幣換算與時區工具'],
|
||||
],
|
||||
whatIs: 'TREK 是什麼?',
|
||||
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
|
||||
selfHost: '開源專案 - ',
|
||||
selfHostLink: '自行架設',
|
||||
close: '知道了',
|
||||
},
|
||||
ar: {
|
||||
titleBefore: 'مرحبًا بك في ',
|
||||
titleAfter: '',
|
||||
title: 'مرحبًا بك في النسخة التجريبية من TREK',
|
||||
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
|
||||
resetIn: 'إعادة الضبط التالية خلال',
|
||||
minutes: 'دقيقة',
|
||||
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
|
||||
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
|
||||
features: [
|
||||
'رفع الملفات (الصور والمستندات وصور الغلاف)',
|
||||
'إدارة مفاتيح API (خرائط Google والطقس)',
|
||||
'إدارة المستخدمين والصلاحيات',
|
||||
'نسخ احتياطية تلقائية',
|
||||
'إدارة الإضافات (تفعيل/تعطيل)',
|
||||
'تسجيل دخول موحد OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
|
||||
addons: [
|
||||
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
|
||||
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
|
||||
['Packing', 'قوائم تجهيز لكل رحلة'],
|
||||
['Budget', 'تتبع المصروفات مع التقسيم'],
|
||||
['Documents', 'إرفاق الملفات بالرحلات'],
|
||||
['Widgets', 'محول عملات ومناطق زمنية'],
|
||||
],
|
||||
whatIs: 'ما هو TREK؟',
|
||||
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
|
||||
selfHost: 'مفتوح المصدر — ',
|
||||
selfHostLink: 'استضفه بنفسك',
|
||||
close: 'فهمت',
|
||||
},
|
||||
}
|
||||
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||
@@ -159,7 +287,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Map size={14} style={{ color: '#111827' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="TREK" style={{ height: 13, marginRight: -2 }} />?
|
||||
{t.whatIs}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||
|
||||
export default function InAppNotificationBell(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
||||
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchUnreadCount()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!open) {
|
||||
fetchNotifications(true)
|
||||
}
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
const handleShowAll = () => {
|
||||
setOpen(false)
|
||||
navigate('/notifications')
|
||||
}
|
||||
|
||||
const displayCount = unreadCount > 99 ? '99+' : unreadCount
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
title={t('notifications.title')}
|
||||
className="relative p-2 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
|
||||
style={{
|
||||
background: '#ef4444',
|
||||
fontSize: 9,
|
||||
minWidth: 14,
|
||||
height: 14,
|
||||
padding: '0 3px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
right: 8,
|
||||
width: 360,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
background: 'var(--bg-card)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--border-secondary)' }}
|
||||
>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={deleteAll}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} />
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import ReactDOM from 'react-dom'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi } from '../../api/client'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||
|
||||
@@ -28,29 +29,21 @@ interface Addon {
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout } = 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 [globalAddons, setGlobalAddons] = useState<Addon[]>([])
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const loadAddons = () => {
|
||||
if (user) {
|
||||
addonsApi.enabled().then(data => {
|
||||
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
useEffect(loadAddons, [user, location.pathname])
|
||||
// Listen for addon changes from AddonManager
|
||||
// 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)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => loadAddons()
|
||||
window.addEventListener('addons-changed', handler)
|
||||
return () => window.removeEventListener('addons-changed', handler)
|
||||
}, [user])
|
||||
if (user) loadAddons()
|
||||
}, [user, location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
import('../../api/client').then(({ authApi }) => {
|
||||
@@ -67,6 +60,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||
}
|
||||
|
||||
const getAddonName = (addon: Addon): string => {
|
||||
const key = `admin.addons.catalog.${addon.id}.name`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : addon.name
|
||||
}
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
||||
@@ -124,7 +123,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="hidden md:inline">{addon.name}</span>
|
||||
<span className="hidden md:inline">{getAddonName(addon)}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -134,7 +133,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{tripTitle && (
|
||||
<>
|
||||
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
||||
<span className="text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="hidden sm:inline text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
|
||||
{tripTitle}
|
||||
</span>
|
||||
</>
|
||||
@@ -156,15 +155,19 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) */}
|
||||
{/* 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"
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
|
||||
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" />}
|
||||
</button>
|
||||
|
||||
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
||||
{user && tripId && <InAppNotificationBell />}
|
||||
{user && !tripId && <span className="hidden sm:block"><InAppNotificationBell /></span>}
|
||||
|
||||
{/* User menu */}
|
||||
{user && (
|
||||
<div className="relative">
|
||||
@@ -230,9 +233,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
{appVersion && (
|
||||
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<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"
|
||||
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)'}
|
||||
title="Discord">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, 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 } from '../shared/categoryIcons'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
|
||||
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 '' }
|
||||
}
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// Fix default marker icons for vite
|
||||
@@ -26,7 +34,12 @@ function escAttr(s) {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const iconCache = new Map<string, L.DivIcon>()
|
||||
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const size = isSelected ? 44 : 36
|
||||
const borderColor = isSelected ? '#111827' : 'white'
|
||||
const borderWidth = isSelected ? 3 : 2.5
|
||||
@@ -34,9 +47,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
? '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'
|
||||
const icon = place.category_icon || '📍'
|
||||
|
||||
// Number badges (bottom-right), supports multiple numbers for duplicate places
|
||||
// Number badges (bottom-right)
|
||||
let badgeHtml = ''
|
||||
if (orderNumbers && orderNumbers.length > 0) {
|
||||
const label = orderNumbers.join(' · ')
|
||||
@@ -54,18 +66,22 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
if (place.image_url) {
|
||||
return L.divIcon({
|
||||
// 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:')) {
|
||||
const imgIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:visible;background:${bgColor};
|
||||
cursor:pointer;flex-shrink:0;position:relative;
|
||||
width:${size}px;height:${size}px;
|
||||
cursor:pointer;position:relative;
|
||||
">
|
||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
|
||||
<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:hidden;background:${bgColor};
|
||||
">
|
||||
<img src="${place.image_url}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
@@ -73,9 +89,11 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
iconCache.set(cacheKey, imgIcon)
|
||||
return imgIcon
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
const fallbackIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
@@ -84,14 +102,17 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
background:${bgColor};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;position:relative;
|
||||
will-change:transform;contain:layout style;
|
||||
">
|
||||
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
|
||||
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)}
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
iconCache.set(cacheKey, fallbackIcon)
|
||||
return fallbackIcon
|
||||
}
|
||||
|
||||
interface SelectionControllerProps {
|
||||
@@ -107,20 +128,14 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||
// Fit all day places into view (so you see context), but ensure selected is visible
|
||||
const toFit = dayPlaces.length > 0 ? dayPlaces : places.filter(p => p.id === selectedPlaceId)
|
||||
const withCoords = toFit.filter(p => p.lat && p.lng)
|
||||
if (withCoords.length > 0) {
|
||||
try {
|
||||
const bounds = L.latLngBounds(withCoords.map(p => [p.lat, p.lng]))
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||
}
|
||||
} catch {}
|
||||
// Pan to the selected place without changing zoom
|
||||
const selected = places.find(p => p.id === selectedPlaceId)
|
||||
if (selected?.lat && selected?.lng) {
|
||||
map.panTo([selected.lat, selected.lng], { animate: true })
|
||||
}
|
||||
}
|
||||
prev.current = selectedPlaceId
|
||||
}, [selectedPlaceId, places, dayPlaces, paddingOpts, map])
|
||||
}, [selectedPlaceId, places, map])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -146,12 +161,13 @@ function MapController({ center, zoom }: MapControllerProps) {
|
||||
|
||||
// Fit bounds when places change (fitKey triggers re-fit)
|
||||
interface BoundsControllerProps {
|
||||
hasDayDetail?: boolean
|
||||
places: Place[]
|
||||
fitKey: number
|
||||
paddingOpts: Record<string, number>
|
||||
}
|
||||
|
||||
function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) {
|
||||
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
|
||||
@@ -161,9 +177,14 @@ function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps
|
||||
if (places.length === 0) return
|
||||
try {
|
||||
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||
if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||
if (hasDayDetail) {
|
||||
setTimeout(() => map.panBy([0, 150], { animate: true }), 300)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [fitKey, places, paddingOpts, map])
|
||||
}, [fitKey, places, paddingOpts, map, hasDayDetail])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -172,6 +193,16 @@ interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||
}
|
||||
|
||||
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
map.on('zoomstart', onZoomStart)
|
||||
map.on('zoomend', onZoomEnd)
|
||||
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
|
||||
}, [map, onZoomStart, onZoomEnd])
|
||||
return null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
@@ -243,10 +274,99 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
}
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
const mapPhotoCache = new Map()
|
||||
const mapPhotoInFlight = new Set()
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
|
||||
export function MapView({
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (position && !centered.current) {
|
||||
map.setView(position, 15)
|
||||
centered.current = true
|
||||
}
|
||||
}, [position, map])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
|
||||
|
||||
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 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pulse animation CSS */}
|
||||
{position && (
|
||||
<style>{`
|
||||
@keyframes location-pulse {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
@@ -263,50 +383,122 @@ export function MapView({
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
}) {
|
||||
// Dynamic padding: account for sidebars + bottom inspector
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
const paddingOpts = useMemo(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (isMobile) return { padding: [40, 20] }
|
||||
const top = 60
|
||||
const bottom = hasInspector ? 320 : 60
|
||||
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||
const left = leftWidth + 40
|
||||
const right = rightWidth + 40
|
||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||
}, [leftWidth, rightWidth, hasInspector])
|
||||
const [photoUrls, setPhotoUrls] = useState({})
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Fetch photos for places (Google or Wikimedia Commons fallback)
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
|
||||
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
places.forEach(place => {
|
||||
if (place.image_url) return
|
||||
if (!places || places.length === 0) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
iconCache.clear()
|
||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||
}
|
||||
|
||||
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) return
|
||||
if (mapPhotoCache.has(cacheKey)) {
|
||||
const cached = mapPhotoCache.get(cacheKey)
|
||||
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
||||
return
|
||||
if (!cacheKey) continue
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached?.thumbDataUrl) {
|
||||
setThumb(cacheKey, cached.thumbDataUrl)
|
||||
continue
|
||||
}
|
||||
if (mapPhotoInFlight.has(cacheKey)) return
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) return
|
||||
mapPhotoInFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then(data => {
|
||||
if (data.photoUrl) {
|
||||
mapPhotoCache.set(cacheKey, data.photoUrl)
|
||||
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
||||
} else {
|
||||
mapPhotoCache.set(cacheKey, null)
|
||||
}
|
||||
mapPhotoInFlight.delete(cacheKey)
|
||||
})
|
||||
.catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) })
|
||||
|
||||
// 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
|
||||
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])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}, [places])
|
||||
}, [])
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || 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 orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
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>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
zoomControl={false}
|
||||
@@ -317,81 +509,32 @@ export function MapView({
|
||||
url={tileUrl}
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
maxZoom={19}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<LocationTracker />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
singleMarkerMode
|
||||
iconCreateFunction={(cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom"
|
||||
style="width:${size}px;height:${size}px;">
|
||||
<span>${count}</span>
|
||||
</div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}}
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
@@ -408,6 +551,24 @@ export 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 }
|
||||
})}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function calculateRoute(
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d`
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const [responding, setResponding] = useState(false)
|
||||
|
||||
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore()
|
||||
|
||||
const handleNavigate = async () => {
|
||||
if (!notification.is_read) await markRead(notification.id)
|
||||
if (notification.navigate_target) {
|
||||
navigate(notification.navigate_target)
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRespond = async (response: 'positive' | 'negative') => {
|
||||
if (responding || notification.response !== null) return
|
||||
setResponding(true)
|
||||
await respondToBoolean(notification.id, response)
|
||||
setResponding(false)
|
||||
}
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative px-4 py-3 transition-colors"
|
||||
style={{
|
||||
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
|
||||
borderBottom: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{!notification.is_read && (
|
||||
<button
|
||||
onClick={() => markRead(notification.id)}
|
||||
title={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'positive'
|
||||
? 'var(--text-primary)'
|
||||
: notification.response === 'negative'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'positive'
|
||||
? '#fff'
|
||||
: notification.response === 'negative'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
opacity: notification.response === 'negative' ? 0.5 : 1,
|
||||
cursor: notification.response !== null || responding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'negative'
|
||||
? '#ef4444'
|
||||
: notification.response === 'positive'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'negative'
|
||||
? '#fff'
|
||||
: notification.response === 'positive'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
opacity: notification.response === 'positive' ? 0.5 : 1,
|
||||
cursor: notification.response !== null || responding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<button
|
||||
onClick={handleNavigate}
|
||||
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9'}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
// Trip PDF via browser print window
|
||||
import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react'
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
return _renderToStaticMarkup(
|
||||
createElement(icon, props)
|
||||
);
|
||||
}
|
||||
|
||||
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
|
||||
function noteIconSvg(iconId) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
const Icon = NOTE_ICON_MAP[iconId] || FileText
|
||||
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
|
||||
}
|
||||
|
||||
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
function transportIconSvg(type) {
|
||||
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })
|
||||
}
|
||||
|
||||
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
|
||||
function accommodationIconSvg(type) {
|
||||
const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' })
|
||||
}
|
||||
|
||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||
@@ -54,15 +72,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
|
||||
|
||||
function shortDate(d, locale) {
|
||||
if (!d) return ''
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function longDateRange(days, locale) {
|
||||
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
|
||||
if (!dd.length) return null
|
||||
const f = new Date(dd[0].date + 'T00:00:00')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
|
||||
const f = new Date(dd[0].date + 'T00:00:00Z')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
|
||||
}
|
||||
|
||||
function dayCost(assignments, dayId, locale) {
|
||||
@@ -96,17 +114,20 @@ interface downloadTripPDFProps {
|
||||
assignments: AssignmentsMap
|
||||
categories: Category[]
|
||||
dayNotes: DayNotesMap
|
||||
reservations?: any[]
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||
await ensureRenderer()
|
||||
const loc = _locale || 'de-DE'
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||
const range = longDateRange(sorted, loc)
|
||||
const coverImg = safeImg(trip?.cover_image)
|
||||
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
||||
const accommodations = await accommodationsApi.list(trip.id);
|
||||
|
||||
// Pre-fetch place photos from Google
|
||||
const photoMap = await fetchPlacePhotos(assignments)
|
||||
@@ -123,15 +144,46 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
|
||||
// Transport bookings for this day
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const dayTransport = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||
})
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayTransport.forEach(r => {
|
||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'transport', k: pos, data: r })
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
|
||||
let pi = 0
|
||||
const itemsHtml = merged.length === 0
|
||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||
: merged.map(item => {
|
||||
if (item.type === 'transport') {
|
||||
const r = item.data
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const icon = transportIconSvg(r.type)
|
||||
let subtitle = ''
|
||||
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid #3b82f6;">
|
||||
<div class="note-line" style="background: #3b82f6;"></div>
|
||||
<span class="note-icon">${icon}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
if (item.type === 'note') {
|
||||
const note = item.data
|
||||
return `
|
||||
@@ -165,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
|
||||
const chips = [
|
||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
||||
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
|
||||
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>` : '',
|
||||
].filter(Boolean).join('')
|
||||
|
||||
return `
|
||||
@@ -184,7 +236,41 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
}).join('')
|
||||
|
||||
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
).sort((a, b) => a.start_day_id - b.start_day_id)
|
||||
|
||||
const accommodationDetails = accommodationsForDay.map(item => {
|
||||
const isCheckIn = day.id === item.start_day_id
|
||||
const isCheckOut = day.id === item.end_day_id
|
||||
const actionLabel = isCheckIn ? tr('reservations.meta.checkIn')
|
||||
: isCheckOut ? tr('reservations.meta.checkOut')
|
||||
: tr('reservations.meta.linkAccommodation')
|
||||
const actionIcon = isCheckIn ? accommodationIconSvg('checkin')
|
||||
: isCheckOut ? accommodationIconSvg('checkout')
|
||||
: accommodationIconSvg('accommodation')
|
||||
const timeStr = isCheckIn ? (item.check_in || '')
|
||||
: isCheckOut ? (item.check_out || '')
|
||||
: ''
|
||||
|
||||
return `
|
||||
<div class="day-accommodation">
|
||||
<div class="day-accommodation-title accommodation-center-icon">${actionIcon} ${escHtml(actionLabel)}</div>
|
||||
${timeStr ? `<div class="accommodation-center-icon">${accommodationIconSvg('checkin')} <b>${escHtml(timeStr)}</b></div>` : ''}
|
||||
<div class="accommodation-center-icon">${accommodationIconSvg('accommodation')} ${escHtml(item.place_name)}</div>
|
||||
${item.place_address ? `<div class="accommodation-center-icon">${accommodationIconSvg('location')} ${escHtml(item.place_address)}</div>` : ''}
|
||||
${item.notes ? `<div class="accommodation-center-icon">${accommodationIconSvg('note')} ${escHtml(item.notes)}</div>` : ''}
|
||||
${isCheckIn && item.confirmation ? `<div class="accommodation-center-icon">${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}</div>` : ''}
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
const accommodationsHtml = accommodationsForDay.length > 0
|
||||
? `<div class="day-accommodations-overview">
|
||||
<div class="day-accommodations ${accommodationsForDay.length === 1 ? 'single' : ''}">${accommodationDetails}</div>
|
||||
</div>`
|
||||
: ''
|
||||
|
||||
return `
|
||||
<div class="day-section${di > 0 ? ' page-break' : ''}">
|
||||
@@ -194,8 +280,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
|
||||
${cost ? `<span class="day-cost">${cost}</span>` : ''}
|
||||
</div>
|
||||
<div class="day-body">${itemsHtml}</div>
|
||||
</div>`
|
||||
<div class="day-body">${accommodationsHtml}${itemsHtml}</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
@@ -278,6 +364,22 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
|
||||
.day-body { padding: 12px 28px 6px; }
|
||||
|
||||
/* accommodation info */
|
||||
.day-accommodations-overview { font-size: 12px; }
|
||||
.day-accommodations { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; }
|
||||
.day-accommodations.single { justify-content: center; }
|
||||
.day-accommodation {
|
||||
flex: 1 1 45%; min-width: 200px; margin: 4px 0; padding: 10px;
|
||||
border: 2px solid #e2e8f0; border-radius: 12px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.day-accommodation-title {
|
||||
font-size: 16px; font-weight: 600; text-align: center;
|
||||
margin-bottom: 4px; align-self: center;
|
||||
}
|
||||
.accommodation-center-icon { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
|
||||
/* ── Place card ────────────────────────────────── */
|
||||
.place-card {
|
||||
display: flex; align-items: stretch;
|
||||
@@ -377,7 +479,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
||||
</div>
|
||||
${totalCost > 0 ? `<div>
|
||||
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
|
||||
<div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { PhotoLightbox } from './PhotoLightbox'
|
||||
import { PhotoUpload } from './PhotoUpload'
|
||||
import { Upload, Camera } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import type { Photo, Place, Day } from '../../types'
|
||||
|
||||
interface PhotoGalleryProps {
|
||||
@@ -17,7 +17,7 @@ interface PhotoGalleryProps {
|
||||
}
|
||||
|
||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, language } = useTranslation()
|
||||
const [lightboxIndex, setLightboxIndex] = useState(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [filterDayId, setFilterDayId] = useState('')
|
||||
@@ -53,7 +53,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
<div style={{ marginRight: 'auto' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
|
||||
{photos.length} Foto{photos.length !== 1 ? 's' : ''}
|
||||
{photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
<option value="">{t('photos.allDays')}</option>
|
||||
{(days || []).map(day => (
|
||||
<option key={day.id} value={day.id}>
|
||||
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
|
||||
{t('planner.dayN', { n: day.day_number })}{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -84,7 +84,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Fotos hochladen
|
||||
{t('common.upload')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
style={{ display: 'inline-flex', margin: '0 auto' }}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Fotos hochladen
|
||||
{t('common.upload')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -146,7 +146,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
<Modal
|
||||
isOpen={showUpload}
|
||||
onClose={() => setShowUpload(false)}
|
||||
title="Fotos hochladen"
|
||||
title={t('common.upload')}
|
||||
size="lg"
|
||||
>
|
||||
<PhotoUpload
|
||||
@@ -211,7 +211,7 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -227,10 +227,10 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
function formatDate(dateStr, locale = 'en-US') {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
return new Date(dateStr).toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
|
||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
import { weatherApi, accommodationsApi } from '../../api/client'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
@@ -50,12 +52,18 @@ interface DayDetailPanelProps {
|
||||
lng: number | null
|
||||
onClose: () => void
|
||||
onAccommodationChange: () => void
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canEditDays = can('day_edit', tripObj)
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const fmtTime = (v) => formatTime12(v, is12h)
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const [weather, setWeather] = useState(null)
|
||||
@@ -108,8 +116,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
setAccommodation(data.accommodation)
|
||||
setAccommodations(prev => [...prev, data.accommodation])
|
||||
const newAcc = data.accommodation
|
||||
const updated = [...accommodations, newAcc]
|
||||
setAccommodations(updated)
|
||||
setAccommodation(newAcc)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
))
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
onAccommodationChange?.()
|
||||
@@ -129,7 +142,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
if (!accommodation) return
|
||||
try {
|
||||
await accommodationsApi.delete(tripId, accommodation.id)
|
||||
setAccommodations(prev => prev.filter(a => a.id !== accommodation.id))
|
||||
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||
setAccommodations(updated)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
))
|
||||
setAccommodation(null)
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
@@ -137,16 +154,16 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
|
||||
if (!day) return null
|
||||
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
||||
language === 'de' ? 'de-DE' : 'en-US',
|
||||
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(
|
||||
getLocaleForLanguage(language),
|
||||
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
|
||||
) : null
|
||||
|
||||
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', width: 'min(800px, calc(100vw - 32px))', zIndex: 50, ...font }}>
|
||||
<div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
@@ -270,7 +287,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</div>
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||
</span>
|
||||
)}
|
||||
@@ -325,13 +342,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
|
||||
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
||||
</div>
|
||||
<button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
</button>}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
{/* Details grid */}
|
||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||
@@ -368,7 +385,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
|
||||
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
{linked.confirmation_number && <span>#{linked.confirmation_number}</span>}
|
||||
{linked.confirmation_number && <span
|
||||
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
|
||||
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
|
||||
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
|
||||
>#{linked.confirmation_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
)
|
||||
})}
|
||||
{/* Add another hotel */}
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
{canEditDays && <button onClick={() => setShowHotelPicker(true)} style={{
|
||||
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Hotel size={10} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
canEditDays ? <button onClick={() => setShowHotelPicker(true)} style={{
|
||||
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Hotel size={12} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
</button> : null
|
||||
)}
|
||||
|
||||
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||
@@ -423,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -435,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -541,8 +563,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
// Reload
|
||||
accommodationsApi.list(tripId).then(d => {
|
||||
setAccommodations(d.accommodations || [])
|
||||
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
const all = d.accommodations || []
|
||||
setAccommodations(all)
|
||||
setDayAccommodations(all.filter(a =>
|
||||
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
|
||||
))
|
||||
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
setAccommodation(acc || null)
|
||||
})
|
||||
onAccommodationChange?.()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -66,6 +68,9 @@ export default function PlaceFormModal({
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { hasMapsKey } = useAuthStore()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canUploadFiles = can('file_upload', tripObj)
|
||||
|
||||
useEffect(() => {
|
||||
if (place) {
|
||||
@@ -104,6 +109,24 @@ export default function PlaceFormModal({
|
||||
if (!mapsSearch.trim()) return
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
// Detect Google Maps URLs and resolve them directly
|
||||
const trimmed = mapsSearch.trim()
|
||||
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
|
||||
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||
if (resolved.lat && resolved.lng) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
name: resolved.name || prev.name,
|
||||
address: resolved.address || prev.address,
|
||||
lat: String(resolved.lat),
|
||||
lng: String(resolved.lng),
|
||||
}))
|
||||
setMapsResults([])
|
||||
setMapsSearch('')
|
||||
toast.success(t('places.urlResolved'))
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err: unknown) {
|
||||
@@ -153,6 +176,7 @@ export default function PlaceFormModal({
|
||||
|
||||
// Paste support for files/images
|
||||
const handlePaste = (e) => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -281,6 +305,15 @@ export default function PlaceFormModal({
|
||||
step="any"
|
||||
value={form.lat}
|
||||
onChange={e => handleChange('lat', e.target.value)}
|
||||
onPaste={e => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
if (match) {
|
||||
e.preventDefault()
|
||||
handleChange('lat', match[1])
|
||||
handleChange('lng', match[2])
|
||||
}
|
||||
}}
|
||||
placeholder={t('places.formLat')}
|
||||
className="form-input"
|
||||
/>
|
||||
@@ -359,7 +392,7 @@ export default function PlaceFormModal({
|
||||
</div>
|
||||
|
||||
{/* File Attachments */}
|
||||
{true && (
|
||||
{canUploadFiles && (
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react'
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -116,16 +119,19 @@ interface PlaceInspectorProps {
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
files: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
tripMembers?: TripMember[]
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
}
|
||||
|
||||
export default function PlaceInspector({
|
||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
}: PlaceInspectorProps) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
@@ -169,7 +175,7 @@ export default function PlaceInspector({
|
||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
|
||||
|
||||
const handleFileUpload = useCallback(async (e) => {
|
||||
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||
@@ -196,9 +202,9 @@ export default function PlaceInspector({
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: '50%',
|
||||
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'min(800px, calc(100vw - 32px))',
|
||||
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
||||
zIndex: 50,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}}
|
||||
@@ -312,7 +318,7 @@ export default function PlaceInspector({
|
||||
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
||||
text={<>
|
||||
{googleDetails.rating.toFixed(1)}
|
||||
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''}
|
||||
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString(locale)})</span> : ''}
|
||||
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · „{shortReview.text}"</span>}
|
||||
</>}
|
||||
color="var(--text-secondary)" bg="var(--bg-hover)"
|
||||
@@ -336,10 +342,8 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || place.notes || googleDetails?.summary) && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
{place.description || place.notes || googleDetails?.summary}
|
||||
</p>
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -369,7 +373,7 @@ export default function PlaceInspector({
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time?.includes('T') && (
|
||||
@@ -388,7 +392,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
|
||||
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
@@ -458,6 +462,98 @@ export default function PlaceInspector({
|
||||
)}
|
||||
|
||||
|
||||
{/* GPX Track stats */}
|
||||
{place.route_geometry && (() => {
|
||||
try {
|
||||
const pts: number[][] = JSON.parse(place.route_geometry)
|
||||
if (!pts || pts.length < 2) return null
|
||||
const hasEle = pts[0].length >= 3
|
||||
|
||||
// Haversine distance
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
let totalDist = 0
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
|
||||
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
|
||||
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
}
|
||||
const distKm = totalDist / 1000
|
||||
|
||||
// Elevation stats
|
||||
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
|
||||
if (hasEle) {
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const e = pts[i][2]
|
||||
if (e < minEle) minEle = e
|
||||
if (e > maxEle) maxEle = e
|
||||
if (i > 0) {
|
||||
const diff = e - pts[i - 1][2]
|
||||
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Elevation profile SVG
|
||||
const chartW = 280, chartH = 60
|
||||
const elevations = hasEle ? pts.map(p => p[2]) : []
|
||||
let pathD = ''
|
||||
if (elevations.length > 1) {
|
||||
const step = Math.max(1, Math.floor(elevations.length / chartW))
|
||||
const sampled = elevations.filter((_, i) => i % step === 0)
|
||||
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
|
||||
const range = eMax - eMin || 1
|
||||
pathD = sampled.map((e, i) => {
|
||||
const x = (i / (sampled.length - 1)) * chartW
|
||||
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<TrendingUp size={13} color="#9ca3af" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<MapPin size={12} color="#3b82f6" />
|
||||
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
|
||||
</div>
|
||||
{hasEle && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<Mountain size={12} color="#22c55e" />
|
||||
{Math.round(maxEle)} m
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<Mountain size={12} color="#ef4444" />
|
||||
{Math.round(minEle)} m
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
↑{Math.round(totalUp)} m ↓{Math.round(totalDown)} m
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{pathD && (
|
||||
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
|
||||
<defs>
|
||||
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
|
||||
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} catch { return null }
|
||||
})()}
|
||||
|
||||
{/* Files section */}
|
||||
{(placeFiles.length > 0 || onFileUpload) && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
@@ -486,11 +582,11 @@ export default function PlaceInspector({
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{placeFiles.map(f => (
|
||||
<a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<button key={f.id} onClick={async () => { const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
|
||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||
</a>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
@@ -22,31 +28,103 @@ interface PlacesSidebarProps {
|
||||
onDeletePlace: (placeId: number) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryId: string) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, pushUndo,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const can = useCanDo()
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
|
||||
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
try {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('places.gpxImported', { count: result.count }))
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGpx'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||
}
|
||||
}
|
||||
|
||||
const [googleListOpen, setGoogleListOpen] = useState(false)
|
||||
const [googleListUrl, setGoogleListUrl] = useState('')
|
||||
const [googleListLoading, setGoogleListLoading] = useState(false)
|
||||
|
||||
const handleGoogleListImport = async () => {
|
||||
if (!googleListUrl.trim()) return
|
||||
setGoogleListLoading(true)
|
||||
try {
|
||||
const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
|
||||
setGoogleListOpen(false)
|
||||
setGoogleListUrl('')
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGoogleList'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.googleListError'))
|
||||
} finally {
|
||||
setGoogleListLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleCategoryFilter = (catId: string) => {
|
||||
setCategoryFiltersLocal(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
||||
// Notify parent with first selected or empty
|
||||
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
|
||||
return next
|
||||
})
|
||||
}
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const plannedIds = new Set(
|
||||
const plannedIds = useMemo(() => new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
)
|
||||
), [assignments])
|
||||
|
||||
const filtered = places.filter(p => {
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
|
||||
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
}), [places, filter, categoryFilters, search, plannedIds])
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
@@ -55,7 +133,7 @@ export default function PlacesSidebar({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Kopfbereich */}
|
||||
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<button
|
||||
{canEditPlaces && <button
|
||||
onClick={onAddPlace}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
@@ -65,7 +143,36 @@ export default function PlacesSidebar({
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>
|
||||
</button>}
|
||||
{canEditPlaces && <>
|
||||
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGoogleListOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')}
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
@@ -100,21 +207,69 @@ export default function PlacesSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kategoriefilter */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CustomSelect
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
placeholder={t('places.allCategories')}
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: '', label: t('places.allCategories') },
|
||||
...categories.map(c => ({ value: String(c.id), label: c.name }))
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Category multi-select dropdown */}
|
||||
{categories.length > 0 && (() => {
|
||||
const label = categoryFilters.size === 0
|
||||
? t('places.allCategories')
|
||||
: categoryFilters.size === 1
|
||||
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
|
||||
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
return (
|
||||
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{catDropOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{categories.map(c => {
|
||||
const active = categoryFilters.has(String(c.id))
|
||||
const CatIcon = getCategoryIcon(c.icon)
|
||||
return (
|
||||
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||
textAlign: 'left',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: active ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: active ? (c.color || 'var(--accent)') : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{active && <Check size={10} strokeWidth={3} color="white" />}
|
||||
</div>
|
||||
<CatIcon size={12} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
|
||||
<span style={{ flex: 1 }}>{c.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{categoryFilters.size > 0 && (
|
||||
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
|
||||
marginTop: 2, borderTop: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<X size={10} /> {t('places.clearFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Anzahl */}
|
||||
@@ -129,9 +284,9 @@ export default function PlacesSidebar({
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
</span>
|
||||
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{canEditPlaces && <button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('places.addPlace')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(place => {
|
||||
@@ -151,19 +306,19 @@ export default function PlacesSidebar({
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMobile && days?.length > 0) {
|
||||
if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
@@ -172,6 +327,8 @@ export default function PlacesSidebar({
|
||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||
@@ -216,49 +373,133 @@ export default function PlacesSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
|
||||
{dayPickerPlace && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => setDayPickerPlace(null)}
|
||||
onClick={() => { setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '60vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{t('places.assignToDay')}</div>
|
||||
{dayPickerPlace.address && <div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{dayPickerPlace.address}</div>}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
||||
{days.map((day, i) => {
|
||||
return (
|
||||
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
|
||||
{/* View details */}
|
||||
<button
|
||||
onClick={() => { onPlaceClick(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Eye size={18} color="var(--text-muted)" /> {t('places.viewDetails')}
|
||||
</button>
|
||||
{/* Edit */}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
onClick={() => { onEditPlace(dayPickerPlace); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Pencil size={18} color="var(--text-muted)" /> {t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
{/* Assign to day */}
|
||||
{days?.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => setMobileShowDays(v => !v)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0,
|
||||
}}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{day.title || `${t('dayplan.dayN', { n: i + 1 })}`}
|
||||
</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
||||
<CalendarDays size={18} color="var(--text-muted)" /> {t('places.assignToDay')}
|
||||
<ChevronDown size={14} style={{ marginLeft: 'auto', color: 'var(--text-faint)', transform: mobileShowDays ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{mobileShowDays && (
|
||||
<div style={{ paddingLeft: 20 }}>
|
||||
{days.map((day, i) => (
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 14px', borderRadius: 10, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0 }}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Delete */}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
onClick={() => { onDeletePlace(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: '#ef4444' }}
|
||||
>
|
||||
<Trash2 size={18} /> {t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{googleListOpen && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
|
||||
{t('places.importGoogleList')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
|
||||
{t('places.googleListHint')}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={googleListUrl}
|
||||
onChange={e => setGoogleListUrl(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
|
||||
placeholder="https://maps.app.goo.gl/..."
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', padding: '10px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)',
|
||||
fontSize: 13, color: 'var(--text-primary)', outline: 'none',
|
||||
fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoogleListImport}
|
||||
disabled={!googleListUrl.trim() || googleListLoading}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{googleListLoading ? t('common.loading') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -267,4 +508,6 @@ export default function PlacesSidebar({
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default PlacesSidebar
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import apiClient from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
@@ -56,21 +60,33 @@ interface ReservationModalProps {
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
files?: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
@@ -78,6 +94,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -87,12 +106,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
useEffect(() => {
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
// Parse end_date from reservation_end_time if it's a full ISO datetime
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
if (rawEnd.includes('T')) {
|
||||
endDate = rawEnd.split('T')[0]
|
||||
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
|
||||
}
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
type: reservation.type || 'other',
|
||||
status: reservation.status || 'pending',
|
||||
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
|
||||
reservation_end_time: reservation.reservation_end_time || '',
|
||||
reservation_end_time: endTime,
|
||||
end_date: endDate,
|
||||
location: reservation.location || '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
@@ -102,6 +130,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_departure_airport: meta.departure_airport || '',
|
||||
meta_arrival_airport: meta.arrival_airport || '',
|
||||
meta_departure_timezone: meta.departure_timezone || '',
|
||||
meta_arrival_timezone: meta.arrival_timezone || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
@@ -110,13 +140,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_out_time: '',
|
||||
})
|
||||
@@ -126,9 +160,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Validate that end datetime is after start datetime
|
||||
const isEndBeforeStart = (() => {
|
||||
if (!form.end_date || !form.reservation_time) return false
|
||||
const startDate = form.reservation_time.split('T')[0]
|
||||
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
||||
const endTime = form.reservation_end_time || '00:00'
|
||||
const startFull = `${startDate}T${startTime}`
|
||||
const endFull = `${form.end_date}T${endTime}`
|
||||
return endFull <= startFull
|
||||
})()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const metadata: Record<string, string> = {}
|
||||
@@ -137,6 +183,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
|
||||
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
|
||||
if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone
|
||||
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
|
||||
} else if (form.type === 'hotel') {
|
||||
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
|
||||
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
|
||||
@@ -145,15 +193,30 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
// Combine end_date + end_time into reservation_end_time
|
||||
let combinedEndTime = form.reservation_end_time
|
||||
if (form.end_date) {
|
||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
const saveData: Record<string, any> = {
|
||||
title: form.title, type: form.type, status: form.status,
|
||||
reservation_time: form.reservation_time, reservation_end_time: form.reservation_end_time,
|
||||
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
|
||||
location: form.location, confirmation_number: form.confirmation_number,
|
||||
notes: form.notes,
|
||||
assignment_id: form.assignment_id || null,
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
}
|
||||
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
||||
if (isBudgetEnabled) {
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
// If hotel with place + days, pass hotel data for auto-creation or update
|
||||
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
@@ -204,7 +267,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
}
|
||||
|
||||
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
|
||||
const attachedFiles = reservation?.id
|
||||
? files.filter(f =>
|
||||
f.reservation_id === reservation.id ||
|
||||
linkedFileIds.includes(f.id) ||
|
||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
||||
)
|
||||
: []
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
@@ -243,10 +312,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* Assignment Picker + Date (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{assignmentOptions.length > 0 && (
|
||||
{/* Assignment Picker (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && assignmentOptions.length > 0 && (
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||
@@ -273,54 +341,81 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, t] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Time + End Time + Status */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{form.type !== 'hotel' && (
|
||||
<>
|
||||
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, t] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const date = d || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{form.type === 'flight' && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const date = d || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<input type="text" value={form.meta_departure_timezone} onChange={e => set('meta_departure_timezone', e.target.value)}
|
||||
placeholder="e.g. CET, UTC+1" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={form.end_date}
|
||||
onChange={d => set('end_date', d || '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<input type="text" value={form.meta_arrival_timezone} onChange={e => set('meta_arrival_timezone', e.target.value)}
|
||||
placeholder="e.g. JST, UTC+9" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEndBeforeStart && (
|
||||
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Location + Booking Code */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@@ -408,8 +503,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Check-in/out times */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Check-in/out times + Status */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
|
||||
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
|
||||
@@ -418,6 +513,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
|
||||
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -459,11 +566,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
||||
{onFileDelete && (
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={async () => {
|
||||
// Always unlink, never delete the file
|
||||
// Clear primary reservation_id if it points to this reservation
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||
}
|
||||
// Remove from file_links if linked there
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||
} catch {}
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
@@ -477,23 +596,98 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>}
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||
</button>
|
||||
{showFilePicker && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||
<button key={f.id} type="button" onClick={async () => {
|
||||
try {
|
||||
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||
setLinkedFileIds(prev => [...prev, f.id])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch {}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category — only shown when budget addon is enabled */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
|
||||
placeholder="0.00"
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -504,6 +698,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -55,31 +57,35 @@ interface ReservationCardProps {
|
||||
files?: TripFile[]
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const [codeRevealed, setCodeRevealed] = useState(false)
|
||||
const typeInfo = getType(r.type)
|
||||
const TypeIcon = typeInfo.Icon
|
||||
const confirmed = r.status === 'confirmed'
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
|
||||
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const handleToggle = async () => {
|
||||
try { await toggleReservationStatus(tripId, r.id) }
|
||||
catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return
|
||||
setShowDeleteConfirm(false)
|
||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
}
|
||||
|
||||
const fmtDate = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
@@ -91,24 +97,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{/* Header bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
@@ -120,21 +136,38 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{r.reservation_time && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time?.includes('T') && r.reservation_end_time.split('T')[0] !== r.reservation_time.split('T')[0] && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time}` : ''}
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.confirmation_number && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{r.confirmation_number}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -151,8 +184,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: meta.check_in_time })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: meta.check_out_time })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||
@@ -227,6 +260,46 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete confirmation popup */}
|
||||
{showDeleteConfirm && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
|
||||
}}>
|
||||
<Trash2 size={18} strokeWidth={1.8} color="#ef4444" />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{t('reservations.confirm.deleteTitle')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('reservations.confirm.deleteBody', { name: r.title })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button onClick={() => setShowDeleteConfirm(false)} style={{
|
||||
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button onClick={handleDelete} style={{
|
||||
fontSize: 12, background: '#ef4444', color: 'white',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>{t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -274,6 +347,9 @@ interface ReservationsPanelProps {
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('reservation_edit', trip)
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||
@@ -292,13 +368,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -314,14 +392,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react'
|
||||
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from './Section'
|
||||
|
||||
interface Props {
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<style>{`
|
||||
@keyframes heartPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
`}</style>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', marginBottom: 6, marginTop: -4 }}>
|
||||
{t('settings.about.description')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, lineHeight: 1.6, color: 'var(--text-faint)', marginBottom: 16 }}>
|
||||
{t('settings.about.madeWith')}{' '}
|
||||
<Heart size={11} fill="#991b1b" stroke="#991b1b" style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }} />
|
||||
{' '}{t('settings.about.madeBy')}{' '}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', verticalAlign: '1px' }}>v{appVersion}</span>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
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' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
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' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} style={{ color: '#ffdd00' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nSdKaXgN"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi, adminApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { UserWithOidc } from '../../types'
|
||||
import Section from './Section'
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
|
||||
export default function AccountTab(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
|
||||
// Profile
|
||||
const [username, setUsername] = useState<string>(user?.username || '')
|
||||
const [email, setEmail] = useState<string>(user?.email || '')
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '')
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
// Password
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [oidcOnlyMode, setOidcOnlyMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then(config => {
|
||||
if (config?.oidc_only_mode) setOidcOnlyMode(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// MFA
|
||||
const [mfaQr, setMfaQr] = useState<string | null>(null)
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('')
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||
const [mfaLoading, setMfaLoading] = useState(false)
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
|
||||
const mfaRequiredByPolicy =
|
||||
!demoMode &&
|
||||
!user?.mfa_enabled &&
|
||||
(searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||
|
||||
const backupCodesText = backupCodes?.join('\n') || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.mfa_enabled || backupCodes) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => typeof x === 'string')) {
|
||||
setBackupCodes(parsed)
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
}
|
||||
}, [user?.mfa_enabled, backupCodes])
|
||||
|
||||
const dismissBackupCodes = () => {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
}
|
||||
|
||||
const copyBackupCodes = async () => {
|
||||
if (!backupCodesText) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesText)
|
||||
toast.success(t('settings.mfa.backupCopied'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'trek-mfa-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const printBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
|
||||
const w = window.open('', '_blank', 'width=900,height=700')
|
||||
if (!w) return
|
||||
w.document.open()
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.focus()
|
||||
w.print()
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
toast.success(t('settings.avatarUploaded'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
{!oidcOnlyMode && (
|
||||
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder={t('settings.newPassword')}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('settings.confirmPassword')}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA */}
|
||||
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div className="flex gap-3 p-3 rounded-lg border text-sm"
|
||||
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||
{demoMode ? (
|
||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
|
||||
{!user?.mfa_enabled && !mfaQr && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const data = await authApi.mfaSetup() as { qr_svg: string; secret: string }
|
||||
setMfaQr(data.qr_svg)
|
||||
setMfaSecret(data.secret)
|
||||
setMfaSetupCode('')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('settings.mfa.setup')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!user?.mfa_enabled && mfaQr && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
|
||||
<div className="rounded-lg border mx-auto block overflow-hidden" style={{ width: 'fit-content', borderColor: 'var(--border-primary)' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
|
||||
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaSetupCode}
|
||||
onChange={e => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || mfaSetupCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
|
||||
toast.success(t('settings.mfa.toastEnabled'))
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
const codes = resp.backup_codes || null
|
||||
if (codes?.length) {
|
||||
try { sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes)) } catch { /* ignore */ }
|
||||
}
|
||||
setBackupCodes(codes)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.enable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
className="px-4 py-2 rounded-lg text-sm border"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.mfa_enabled && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={mfaDisablePwd}
|
||||
onChange={e => setMfaDisablePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
|
||||
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
|
||||
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
|
||||
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||
</button>
|
||||
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Download size={13} /> {t('settings.mfa.backupDownload')}
|
||||
</button>
|
||||
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Printer size={13} /> {t('settings.mfa.backupPrint')}
|
||||
</button>
|
||||
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24, fontWeight: 700,
|
||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} style={{ display: 'none' }} />
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
style={{
|
||||
position: 'absolute', bottom: -3, right: -3,
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
{user?.avatar_url && (
|
||||
<button
|
||||
onClick={handleAvatarRemove}
|
||||
style={{
|
||||
position: 'absolute', top: -2, right: -2,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: '#ef4444', color: 'white',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
|
||||
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
||||
</span>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
|
||||
background: '#dbeafe', color: '#1d4ed8', marginLeft: 6,
|
||||
}}>
|
||||
SSO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
|
||||
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
|
||||
<span className="sm:hidden">{t('common.save')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
|
||||
style={{ border: '1px solid #fecaca' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
|
||||
<span className="sm:hidden">{t('common.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} style={{ color: '#d97706' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteBlockedMessage')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.ok') || 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirm */}
|
||||
{showDeleteConfirm === true && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteAccountWarning')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||
border: 'none', background: '#ef4444', color: 'white',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('settings.deleteAccountConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from './Section'
|
||||
|
||||
export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
|
||||
useEffect(() => {
|
||||
setTempUnit(settings.temperature_unit || 'celsius')
|
||||
}, [settings.temperature_unit])
|
||||
|
||||
return (
|
||||
<Section title={t('settings.display')} icon={Palette}>
|
||||
{/* Color Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
|
||||
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ value: 'light', label: t('settings.light'), icon: Sun },
|
||||
{ value: 'dark', label: t('settings.dark'), icon: Moon },
|
||||
{ value: 'auto', label: t('settings.auto'), icon: Monitor },
|
||||
].map(opt => {
|
||||
const current = settings.dark_mode
|
||||
const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 14px', borderRadius: 10, cursor: 'pointer', flex: '1 1 0', justifyContent: 'center', minWidth: 0,
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.temperature')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Format */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('blur_booking_codes', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import Section from './Section'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react'
|
||||
import { authApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
|
||||
|
||||
interface McpToken {
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
}, [loadAddons])
|
||||
|
||||
// MCP state
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [mcpNewName, setMcpNewName] = useState('')
|
||||
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
|
||||
const [mcpCreating, setMcpCreating] = useState(false)
|
||||
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
|
||||
const mcpEndpoint = `${window.location.origin}/mcp`
|
||||
const mcpJsonConfig = `{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${mcpEndpoint}",
|
||||
"--header",
|
||||
"Authorization: Bearer <your_token>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
useEffect(() => {
|
||||
if (mcpEnabled) {
|
||||
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
|
||||
}
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateMcpToken = async () => {
|
||||
if (!mcpNewName.trim()) return
|
||||
setMcpCreating(true)
|
||||
try {
|
||||
const d = await authApi.mcpTokens.create(mcpNewName.trim())
|
||||
setMcpCreatedToken(d.token.raw_token)
|
||||
setMcpNewName('')
|
||||
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.createError'))
|
||||
} finally {
|
||||
setMcpCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMcpToken = async (id: number) => {
|
||||
try {
|
||||
await authApi.mcpTokens.delete(id)
|
||||
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setMcpDeleteId(null)
|
||||
toast.success(t('settings.mcp.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
{mcpEnabled && (
|
||||
<Section title={t('settings.mcp.title')} icon={Terminal}>
|
||||
{/* Endpoint URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.endpoint')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpEndpoint}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON config box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
|
||||
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpJsonConfig}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpTokens.length === 0 ? (
|
||||
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 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}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(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('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Create MCP Token modal */}
|
||||
{mcpModalOpen && (
|
||||
<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 && !mcpCreatedToken) setMcpModalOpen(false) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
{!mcpCreatedToken ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createTitle')}</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.tokenName')}</label>
|
||||
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button onClick={() => setMcpModalOpen(false)}
|
||||
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={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createdTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.createdWarning')}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpCreatedToken}
|
||||
</pre>
|
||||
<button onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
|
||||
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete MCP Token confirm */}
|
||||
{mcpDeleteId !== 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) setMcpDeleteId(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('settings.mcp.deleteTokenTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setMcpDeleteId(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={() => handleDeleteMcpToken(mcpDeleteId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.mcp.deleteTokenTitle')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Map, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import Section from './Section'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const MAP_PRESETS: MapPreset[] = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
export default function MapSettingsTab(): React.ReactElement {
|
||||
const { settings, updateSettings } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
setDefaultLng(settings.default_lng || 2.3522)
|
||||
setDefaultZoom(settings.default_zoom || 10)
|
||||
}, [settings])
|
||||
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
setDefaultLng(mapInfo.latlng.lng)
|
||||
}, [])
|
||||
|
||||
const mapPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Default map center',
|
||||
description: '',
|
||||
lat: defaultLat as number,
|
||||
lng: defaultLng as number,
|
||||
address: '',
|
||||
category_id: 0,
|
||||
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: Date(),
|
||||
}], [defaultLat, defaultLng])
|
||||
|
||||
const saveMapSettings = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateSettings({
|
||||
map_tile_url: mapTileUrl,
|
||||
default_lat: parseFloat(String(defaultLat)),
|
||||
default_lng: parseFloat(String(defaultLng)),
|
||||
default_zoom: parseInt(String(defaultZoom)),
|
||||
})
|
||||
toast.success(t('settings.toast.mapSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(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)}
|
||||
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 text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLat}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.longitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLng}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: handleMapClick,
|
||||
onMapContextMenu: null,
|
||||
center: [settings.default_lat, settings.default_lng],
|
||||
zoom: defaultZoom,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveMap')}
|
||||
</button>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { notificationsApi, settingsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import Section from './Section'
|
||||
|
||||
interface PreferencesMatrix {
|
||||
preferences: Record<string, Record<string, boolean>>
|
||||
available_channels: { email: boolean; webhook: boolean; inapp: boolean }
|
||||
event_types: string[]
|
||||
implemented_combos: Record<string, string[]>
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
}
|
||||
|
||||
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
trip_invite: 'settings.notifyTripInvite',
|
||||
booking_change: 'settings.notifyBookingChange',
|
||||
trip_reminder: 'settings.notifyTripReminder',
|
||||
vacay_invite: 'settings.notifyVacayInvite',
|
||||
photos_shared: 'settings.notifyPhotosShared',
|
||||
collab_message: 'settings.notifyCollabMessage',
|
||||
packing_tagged: 'settings.notifyPackingTagged',
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
}
|
||||
|
||||
export default function NotificationsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [webhookIsSet, setWebhookIsSet] = useState(false)
|
||||
const [webhookSaving, setWebhookSaving] = useState(false)
|
||||
const [webhookTesting, setWebhookTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
||||
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
|
||||
const val = (data.settings?.webhook_url as string) || ''
|
||||
if (val === '••••••••') {
|
||||
setWebhookIsSet(true)
|
||||
setWebhookUrl('')
|
||||
} else {
|
||||
setWebhookUrl(val)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const visibleChannels = matrix
|
||||
? (['email', 'webhook', 'inapp'] as const).filter(ch => {
|
||||
if (!matrix.available_channels[ch]) return false
|
||||
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
|
||||
})
|
||||
: []
|
||||
|
||||
const toggle = async (eventType: string, channel: string) => {
|
||||
if (!matrix) return
|
||||
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||
const updated = {
|
||||
...matrix.preferences,
|
||||
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
|
||||
}
|
||||
setMatrix(m => m ? { ...m, preferences: updated } : m)
|
||||
setSaving(true)
|
||||
try {
|
||||
await notificationsApi.updatePreferences(updated)
|
||||
} catch {
|
||||
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveWebhookUrl = async () => {
|
||||
setWebhookSaving(true)
|
||||
try {
|
||||
await settingsApi.set('webhook_url', webhookUrl)
|
||||
if (webhookUrl) setWebhookIsSet(true)
|
||||
else setWebhookIsSet(false)
|
||||
toast.success(t('settings.webhookUrl.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setWebhookSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testWebhookUrl = async () => {
|
||||
if (!webhookUrl && !webhookIsSet) return
|
||||
setWebhookTesting(true)
|
||||
try {
|
||||
const result = await notificationsApi.testWebhook(webhookUrl || undefined)
|
||||
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
|
||||
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
|
||||
} catch {
|
||||
toast.error(t('settings.webhookUrl.testFailed'))
|
||||
} finally {
|
||||
setWebhookTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading…</p>
|
||||
|
||||
if (visibleChannels.length === 0) {
|
||||
return (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{t('settings.notificationPreferences.noChannels')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||
{matrix.available_channels.webhook && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.webhookUrl.label')}
|
||||
</label>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={webhookUrl}
|
||||
onChange={e => setWebhookUrl(e.target.value)}
|
||||
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
|
||||
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={saveWebhookUrl}
|
||||
disabled={webhookSaving}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={testWebhookUrl}
|
||||
disabled={(!webhookUrl && !webhookIsSet) || webhookTesting}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.test')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t(CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map(eventType => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch))
|
||||
if (relevantChannels.length === 0) return null
|
||||
return (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch)) {
|
||||
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||
return (
|
||||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.notifications')} icon={Lock}>
|
||||
{renderContent()}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Camera, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import apiClient from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Section from './Section'
|
||||
|
||||
interface ProviderField {
|
||||
key: string
|
||||
label: string
|
||||
input_type: string
|
||||
placeholder?: string | null
|
||||
required: boolean
|
||||
secret: boolean
|
||||
settings_key?: string | null
|
||||
payload_key?: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface PhotoProviderAddon {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
fields?: ProviderField[]
|
||||
}
|
||||
|
||||
interface ProviderConfig {
|
||||
settings_get?: string
|
||||
settings_put?: string
|
||||
status_get?: string
|
||||
test_get?: string
|
||||
test_post?: string
|
||||
}
|
||||
|
||||
const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => {
|
||||
const raw = provider.config || {}
|
||||
return {
|
||||
settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined,
|
||||
settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined,
|
||||
status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined,
|
||||
test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined,
|
||||
test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => {
|
||||
return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order)
|
||||
}
|
||||
|
||||
export default function PhotoProvidersSection(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { isEnabled: addonEnabled, addons } = useAddonStore()
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
const [providerValues, setProviderValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [providerConnected, setProviderConnected] = useState<Record<string, boolean>>({})
|
||||
const [providerTesting, setProviderTesting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const activePhotoProviders = useMemo(
|
||||
() => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[],
|
||||
[addons],
|
||||
)
|
||||
|
||||
const buildProviderPayload = (provider: PhotoProviderAddon): Record<string, unknown> => {
|
||||
const values = providerValues[provider.id] || {}
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const field of getProviderFields(provider)) {
|
||||
const payloadKey = field.payload_key || field.settings_key || field.key
|
||||
const value = (values[field.key] || '').trim()
|
||||
if (field.secret && !value) continue
|
||||
payload[payloadKey] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const refreshProviderConnection = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const statusPath = cfg.status_get
|
||||
if (!statusPath) return
|
||||
try {
|
||||
const res = await apiClient.get(statusPath)
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected }))
|
||||
} catch {
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const activeProviderSignature = useMemo(
|
||||
() => activePhotoProviders.map(provider => provider.id).join('|'),
|
||||
[activePhotoProviders],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
for (const provider of activePhotoProviders) {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const fields = getProviderFields(provider)
|
||||
|
||||
if (cfg.settings_get) {
|
||||
apiClient.get(cfg.settings_get).then(res => {
|
||||
if (isCancelled) return
|
||||
|
||||
const nextValues: Record<string, string> = {}
|
||||
for (const field of fields) {
|
||||
// Do not prefill secret fields; user can overwrite only when needed.
|
||||
if (field.secret) continue
|
||||
const sourceKey = field.settings_key || field.payload_key || field.key
|
||||
const rawValue = (res.data as Record<string, unknown>)[sourceKey]
|
||||
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : ''
|
||||
}
|
||||
setProviderValues(prev => ({
|
||||
...prev,
|
||||
[provider.id]: { ...(prev[provider.id] || {}), ...nextValues },
|
||||
}))
|
||||
if (typeof res.data?.connected === 'boolean') {
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected }))
|
||||
}
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
refreshProviderConnection(provider).catch(() => { })
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [activePhotoProviders, activeProviderSignature])
|
||||
|
||||
const handleProviderFieldChange = (providerId: string, key: string, value: string) => {
|
||||
setProviderValues(prev => ({
|
||||
...prev,
|
||||
[providerId]: { ...(prev[providerId] || {}), [key]: value },
|
||||
}))
|
||||
}
|
||||
|
||||
const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => {
|
||||
const values = providerValues[provider.id] || {}
|
||||
return getProviderFields(provider).some(field => {
|
||||
if (!field.required) return false
|
||||
return !(values[field.key] || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveProvider = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
if (!cfg.settings_put) return
|
||||
setSaving(s => ({ ...s, [provider.id]: true }))
|
||||
try {
|
||||
await apiClient.put(cfg.settings_put, buildProviderPayload(provider))
|
||||
await refreshProviderConnection(provider)
|
||||
toast.success(t('memories.saved', { provider_name: provider.name }))
|
||||
} catch {
|
||||
toast.error(t('memories.saveError', { provider_name: provider.name }))
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestProvider = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const testPath = cfg.test_post || cfg.test_get || cfg.status_get
|
||||
if (!testPath) return
|
||||
setProviderTesting(prev => ({ ...prev, [provider.id]: true }))
|
||||
try {
|
||||
const payload = buildProviderPayload(provider)
|
||||
const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath)
|
||||
const ok = !!res.data?.connected
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: ok }))
|
||||
if (ok) {
|
||||
toast.success(t('memories.connectionSuccess', { provider_name: provider.name }))
|
||||
} else {
|
||||
toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError', { provider_name: provider.name }))
|
||||
} finally {
|
||||
setProviderTesting(prev => ({ ...prev, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => {
|
||||
const fields = getProviderFields(provider)
|
||||
const cfg = getProviderConfig(provider)
|
||||
const values = providerValues[provider.id] || {}
|
||||
const connected = !!providerConnected[provider.id]
|
||||
const testing = !!providerTesting[provider.id]
|
||||
const canSave = !!cfg.settings_put
|
||||
const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get)
|
||||
|
||||
return (
|
||||
<Section key={provider.id} title={provider.name || provider.id} icon={Camera}>
|
||||
<div className="space-y-3">
|
||||
{fields.map(field => (
|
||||
<div key={`${provider.id}-${field.key}`}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||
<input
|
||||
type={field.input_type || 'text'}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
|
||||
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!canSave ? 'Save route is not configured for this provider' : isProviderSaveDisabled(provider) ? 'Please fill all required fields' : ''}
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTestProvider(provider)}
|
||||
disabled={!canTest || testing}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||
title={!canTest ? 'Test route is not configured for this provider' : ''}
|
||||
>
|
||||
{testing
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{connected && (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!memoriesEnabled) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <>{activePhotoProviders.map(provider => renderPhotoProviderSection(provider))}</>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', marginBottom: 24 }}>
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button onClick={onToggle}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: on ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import apiClient from '../../api/client'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { formatDate as fmtDate } from '../../utils/formatters'
|
||||
import {
|
||||
CheckSquare, Square, Plus, ChevronRight, Flag,
|
||||
X, Check, Calendar, User, FolderPlus, AlertCircle, ListChecks, Inbox, CheckCheck, Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { TodoItem } from '../../types'
|
||||
|
||||
const KAT_COLORS = [
|
||||
'#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316',
|
||||
'#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6',
|
||||
]
|
||||
|
||||
const PRIO_CONFIG: Record<number, { label: string; color: string }> = {
|
||||
1: { label: 'P1', color: '#ef4444' },
|
||||
2: { label: 'P2', color: '#f59e0b' },
|
||||
3: { label: 'P3', color: '#3b82f6' },
|
||||
}
|
||||
|
||||
function katColor(kat: string, allCategories: string[]) {
|
||||
const idx = allCategories.indexOf(kat)
|
||||
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
|
||||
let h = 0
|
||||
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
|
||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
|
||||
|
||||
interface Member { id: number; username: string; avatar: string | null }
|
||||
|
||||
export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
|
||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
|
||||
const canEdit = useCanDo('packing_edit')
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const formatDate = (d: string) => fmtDate(d, locale) || d
|
||||
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 767px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const [filter, setFilter] = useState<FilterType>('all')
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [isAddingNew, setIsAddingNew] = useState(false)
|
||||
const [sortByPrio, setSortByPrio] = useState(false)
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get(`/trips/${tripId}/members`).then(r => {
|
||||
const owner = r.data?.owner
|
||||
const mems = r.data?.members || []
|
||||
const all = owner ? [owner, ...mems] : mems
|
||||
setMembers(all)
|
||||
setCurrentUserId(r.data?.current_user_id || null)
|
||||
}).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
items.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [items])
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result: TodoItem[]
|
||||
if (filter === 'all') result = items.filter(i => !i.checked)
|
||||
else if (filter === 'done') result = items.filter(i => !!i.checked)
|
||||
else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId)
|
||||
else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today)
|
||||
else result = items.filter(i => i.category === filter)
|
||||
if (sortByPrio) result = [...result].sort((a, b) => {
|
||||
const ap = a.priority || 99
|
||||
const bp = b.priority || 99
|
||||
return ap - bp
|
||||
})
|
||||
return result
|
||||
}, [items, filter, currentUserId, today, sortByPrio])
|
||||
|
||||
const selectedItem = items.find(i => i.id === selectedId) || null
|
||||
const totalCount = items.length
|
||||
const doneCount = items.filter(i => !!i.checked).length
|
||||
const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length
|
||||
const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0
|
||||
|
||||
const addCategory = () => {
|
||||
const name = newCategoryName.trim()
|
||||
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
|
||||
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
|
||||
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : 'Error'))
|
||||
}
|
||||
|
||||
// Get category count (non-done items)
|
||||
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
|
||||
|
||||
// Sidebar filter item
|
||||
const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => (
|
||||
<button onClick={() => setFilter(id as FilterType)}
|
||||
title={isMobile ? label : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
background: filter === id ? 'var(--bg-hover)' : 'transparent',
|
||||
color: filter === id ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: filter === id ? 600 : 400, transition: 'all 0.1s',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={e => { if (filter !== id) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (filter !== id) e.currentTarget.style.background = 'transparent' }}>
|
||||
{color ? (
|
||||
<span style={{ width: isMobile ? 12 : 10, height: isMobile ? 12 : 10, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
) : (
|
||||
<Icon size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
)}
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{label}</span>}
|
||||
{!isMobile && count > 0 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', background: 'var(--bg-hover)', borderRadius: 10, padding: '1px 7px', minWidth: 20, textAlign: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{isMobile && count > 0 && (
|
||||
<span style={{ position: 'absolute', top: 2, right: 2, fontSize: 8, fontWeight: 700, color: 'var(--bg-primary)', background: 'var(--text-faint)', borderRadius: '50%', width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
// Filter title
|
||||
const filterTitle = (() => {
|
||||
if (filter === 'all') return t('todo.filter.all')
|
||||
if (filter === 'done') return t('todo.filter.done')
|
||||
if (filter === 'my') return t('todo.filter.my')
|
||||
if (filter === 'overdue') return t('todo.filter.overdue')
|
||||
return filter
|
||||
})()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 180px)', minHeight: 400 }}>
|
||||
|
||||
{/* ── Left Sidebar ── */}
|
||||
<div style={{
|
||||
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
|
||||
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
||||
transition: 'width 0.2s',
|
||||
}}>
|
||||
{/* Progress Card */}
|
||||
{!isMobile && <div style={{
|
||||
margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
|
||||
background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-0.02em' }}>
|
||||
{totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: 'var(--border-faint)', borderRadius: 2, overflow: 'hidden', marginBottom: 6 }}>
|
||||
<div style={{ height: '100%', width: totalCount > 0 ? `${Math.round((doneCount / totalCount) * 100)}%` : '0%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{doneCount} / {totalCount} {t('todo.completed')}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Smart filters */}
|
||||
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('todo.sidebar.tasks')}
|
||||
</div>}
|
||||
<SidebarItem id="all" icon={Inbox} label={t('todo.filter.all')} count={items.filter(i => !i.checked).length} />
|
||||
<SidebarItem id="my" icon={User} label={t('todo.filter.my')} count={myCount} />
|
||||
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
|
||||
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
|
||||
|
||||
{/* Sort by priority */}
|
||||
<button onClick={() => setSortByPrio(v => !v)}
|
||||
title={isMobile ? t('todo.sortByPrio') : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
background: sortByPrio ? '#f59e0b12' : 'transparent',
|
||||
color: sortByPrio ? '#f59e0b' : 'var(--text-secondary)',
|
||||
fontWeight: sortByPrio ? 600 : 400, transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
|
||||
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
|
||||
</button>
|
||||
|
||||
{/* Categories */}
|
||||
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('todo.sidebar.categories')}
|
||||
</div>}
|
||||
{isMobile && <div style={{ height: 1, background: 'var(--border-faint)', margin: '8px 4px' }} />}
|
||||
{categories.map(cat => (
|
||||
<SidebarItem key={cat} id={cat} icon={null} label={cat} count={catCount(cat)} color={katColor(cat, categories)} />
|
||||
))}
|
||||
|
||||
{canEdit && (
|
||||
addingCategory && !isMobile ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 12px' }}>
|
||||
<input autoFocus value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCategoryName('') } }}
|
||||
placeholder={t('todo.newCategory')}
|
||||
style={{ flex: 1, fontSize: 12, padding: '4px 6px', border: '1px solid var(--border-primary)', borderRadius: 5, background: 'var(--bg-hover)', color: 'var(--text-primary)', fontFamily: 'inherit', minWidth: 0 }} />
|
||||
<button onClick={addCategory} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#22c55e', padding: 2 }}><Check size={13} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setAddingCategory(true)}
|
||||
title={isMobile ? t('todo.addCategory') : undefined}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start', gap: isMobile ? 0 : 6, padding: isMobile ? '8px 0' : '7px 12px', fontSize: 12, color: 'var(--text-faint)', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', width: '100%', textAlign: 'left' }}>
|
||||
<Plus size={isMobile ? 18 : 13} /> {!isMobile && t('todo.addCategory')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Middle: Task List ── */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.02em' }}>
|
||||
{filterTitle}
|
||||
</h2>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)', background: 'var(--bg-hover)', borderRadius: 6, padding: '2px 8px', fontWeight: 600 }}>
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add task */}
|
||||
{canEdit && (
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<button
|
||||
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '9px 16px', borderRadius: 8,
|
||||
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
|
||||
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
|
||||
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
|
||||
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
|
||||
<Plus size={14} />
|
||||
{t('todo.addItem')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
||||
{filtered.length === 0 ? null : (
|
||||
filtered.map(item => {
|
||||
const done = !!item.checked
|
||||
const assignedUser = members.find(m => m.id === item.assigned_user_id)
|
||||
const isOverdue = item.due_date && !done && item.due_date < today
|
||||
const isSelected = selectedId === item.id
|
||||
const catColor = item.category ? katColor(item.category, categories) : null
|
||||
|
||||
return (
|
||||
<div key={item.id}
|
||||
onClick={() => { setSelectedId(isSelected ? null : item.id); setIsAddingNew(false) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
|
||||
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
|
||||
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
|
||||
|
||||
{/* Checkbox */}
|
||||
<button onClick={e => { e.stopPropagation(); canEdit && toggleTodoItem(tripId, item.id, !done) }}
|
||||
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
|
||||
color: done ? '#22c55e' : 'var(--border-primary)' }}>
|
||||
{done ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 14, color: done ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
textDecoration: done ? 'line-through' : 'none', lineHeight: 1.4,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{item.name}
|
||||
</div>
|
||||
{/* Description preview */}
|
||||
{item.description && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
{/* Inline badges */}
|
||||
{(item.priority || item.due_date || catColor || assignedUser) && (
|
||||
<div style={{ display: 'flex', gap: 5, marginTop: 5, flexWrap: 'wrap' }}>
|
||||
{item.priority > 0 && PRIO_CONFIG[item.priority] && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 600,
|
||||
color: PRIO_CONFIG[item.priority].color,
|
||||
background: `${PRIO_CONFIG[item.priority].color}10`,
|
||||
border: `1px solid ${PRIO_CONFIG[item.priority].color}25`,
|
||||
}}>
|
||||
<Flag size={9} />{PRIO_CONFIG[item.priority].label}
|
||||
</span>
|
||||
)}
|
||||
{item.due_date && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: isOverdue ? '#ef4444' : 'var(--text-secondary)',
|
||||
background: isOverdue ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)',
|
||||
border: `1px solid ${isOverdue ? 'rgba(239,68,68,0.15)' : 'var(--border-faint)'}`,
|
||||
}}>
|
||||
<Calendar size={9} />{formatDate(item.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{catColor && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: catColor, flexShrink: 0 }} />
|
||||
{item.category}
|
||||
</span>
|
||||
)}
|
||||
{assignedUser && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{assignedUser.avatar ? (
|
||||
<img src={`/uploads/avatars/${assignedUser.avatar}`} style={{ width: 13, height: 13, borderRadius: '50%', objectFit: 'cover' }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 13, height: 13, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, color: 'var(--text-faint)', fontWeight: 700 }}>
|
||||
{assignedUser.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{assignedUser.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight size={16} color="var(--text-faint)" style={{ flexShrink: 0, opacity: 0.4 }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Detail Pane ── */}
|
||||
{selectedItem && !isAddingNew && !isMobile && (
|
||||
<DetailPane
|
||||
item={selectedItem}
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
{selectedItem && !isAddingNew && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<DetailPane
|
||||
item={selectedItem}
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && !isMobile && (
|
||||
<NewTaskPane
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<NewTaskPane
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Detail Pane (right side) ──────────────────────────────────────────────
|
||||
|
||||
function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
item: TodoItem; tripId: number; categories: string[]; members: Member[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { updateTodoItem, deleteTodoItem } = useTripStore()
|
||||
const canEdit = useCanDo('packing_edit')
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [name, setName] = useState(item.name)
|
||||
const [desc, setDesc] = useState(item.description || '')
|
||||
const [dueDate, setDueDate] = useState(item.due_date || '')
|
||||
const [category, setCategory] = useState(item.category || '')
|
||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
||||
const [priority, setPriority] = useState(item.priority || 0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Sync when selected item changes
|
||||
useEffect(() => {
|
||||
setName(item.name)
|
||||
setDesc(item.description || '')
|
||||
setDueDate(item.due_date || '')
|
||||
setCategory(item.category || '')
|
||||
setAssignedUserId(item.assigned_user_id)
|
||||
setPriority(item.priority || 0)
|
||||
}, [item.id, item.name, item.description, item.due_date, item.category, item.assigned_user_id, item.priority])
|
||||
|
||||
const hasChanges = name !== item.name || desc !== (item.description || '') ||
|
||||
dueDate !== (item.due_date || '') || category !== (item.category || '') ||
|
||||
assignedUserId !== item.assigned_user_id || priority !== (item.priority || 0)
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim() || !hasChanges) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateTodoItem(tripId, item.id, {
|
||||
name: name.trim(), description: desc || null,
|
||||
due_date: dueDate || null, category: category || null,
|
||||
assigned_user_id: assignedUserId, priority,
|
||||
} as any)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteTodoItem(tripId, item.id)
|
||||
onClose()
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 320, flexShrink: 0, borderLeft: '1px solid var(--border-faint)',
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{t('todo.detail.title')}</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4 }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<input value={name} onChange={e => setName(e.target.value)} disabled={!canEdit}
|
||||
style={{ ...inputStyle, fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent' }}
|
||||
placeholder={t('todo.namePlaceholder')} />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.description')}</label>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} disabled={!canEdit} rows={4}
|
||||
placeholder={t('todo.descriptionPlaceholder')}
|
||||
style={{ ...inputStyle, resize: 'vertical', minHeight: 80 }} />
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.priority')}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[0, 1, 2, 3].map(p => {
|
||||
const cfg = PRIO_CONFIG[p]
|
||||
const isActive = priority === p
|
||||
return (
|
||||
<button key={p} onClick={() => canEdit && setPriority(p)}
|
||||
style={{
|
||||
flex: 1, padding: '6px 0', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: canEdit ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
border: `1px solid ${isActive && cfg ? cfg.color + '40' : 'var(--border-primary)'}`,
|
||||
background: isActive && cfg ? cfg.color + '12' : 'transparent',
|
||||
color: isActive && cfg ? cfg.color : isActive ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.1s',
|
||||
}}>
|
||||
{cfg ? <><Flag size={10} />{cfg.label}</> : t('todo.detail.noPriority')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(v)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c,
|
||||
label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.dueDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={dueDate}
|
||||
onChange={v => setDueDate(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assigned to */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.assignedTo')}</label>
|
||||
<CustomSelect
|
||||
value={String(assignedUserId ?? '')}
|
||||
onChange={v => setAssignedUserId(v ? Number(v) : null)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> },
|
||||
...members.map(m => ({
|
||||
value: String(m.id),
|
||||
label: m.username,
|
||||
icon: m.avatar ? (
|
||||
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: 'var(--text-faint)', fontWeight: 600 }}>
|
||||
{m.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.unassigned')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{canEdit && (
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
|
||||
<button onClick={handleDelete}
|
||||
style={{
|
||||
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: '1px solid var(--border-primary)', background: 'transparent', color: 'var(--text-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
}}>
|
||||
<Trash2 size={13} />
|
||||
{t('todo.detail.delete')}
|
||||
</button>
|
||||
<button onClick={save} disabled={!hasChanges || saving}
|
||||
style={{
|
||||
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: hasChanges ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
border: 'none', background: hasChanges ? 'var(--text-primary)' : 'var(--border-faint)',
|
||||
color: hasChanges ? 'var(--bg-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{saving ? '...' : t('todo.detail.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── New Task Pane (right side, for creating) ──────────────────────────────
|
||||
|
||||
function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated, onClose }: {
|
||||
tripId: number; categories: string[]; members: Member[]; defaultCategory: string | null;
|
||||
onCreated: (id: number) => void; onClose: () => void;
|
||||
}) {
|
||||
const { addTodoItem } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [desc, setDesc] = useState('')
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [category, setCategory] = useState(defaultCategory || '')
|
||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const item = await addTodoItem(tripId, {
|
||||
name: name.trim(), description: desc || null, priority,
|
||||
due_date: dueDate || null, category: category || null,
|
||||
assigned_user_id: assignedUserId,
|
||||
} as any)
|
||||
if (item?.id) onCreated(item.id)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 320, flexShrink: 0, borderLeft: '1px solid var(--border-faint)',
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{t('todo.newItem')}</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4 }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<input autoFocus value={name} onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && name.trim()) create() }}
|
||||
style={{ width: '100%', fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent', color: 'var(--text-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||
placeholder={t('todo.namePlaceholder')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.description')}</label>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={4}
|
||||
placeholder={t('todo.descriptionPlaceholder')}
|
||||
style={{ width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', resize: 'vertical', minHeight: 80 }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(v)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c, label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.priority')}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[0, 1, 2, 3].map(p => {
|
||||
const cfg = PRIO_CONFIG[p]
|
||||
const isActive = priority === p
|
||||
return (
|
||||
<button key={p} onClick={() => setPriority(p)}
|
||||
style={{
|
||||
flex: 1, padding: '6px 0', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
border: `1px solid ${isActive && cfg ? cfg.color + '40' : 'var(--border-primary)'}`,
|
||||
background: isActive && cfg ? cfg.color + '12' : 'transparent',
|
||||
color: isActive && cfg ? cfg.color : isActive ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.1s',
|
||||
}}>
|
||||
{cfg ? <><Flag size={10} />{cfg.label}</> : t('todo.detail.noPriority')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.dueDate')}</label>
|
||||
<CustomDatePicker value={dueDate} onChange={v => setDueDate(v)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.assignedTo')}</label>
|
||||
<CustomSelect
|
||||
value={String(assignedUserId ?? '')}
|
||||
onChange={v => setAssignedUserId(v ? Number(v) : null)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> },
|
||||
...members.map(m => ({
|
||||
value: String(m.id), label: m.username,
|
||||
icon: m.avatar ? (
|
||||
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: 'var(--text-faint)', fontWeight: 600 }}>
|
||||
{m.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.unassigned')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-faint)' }}>
|
||||
<button onClick={create} disabled={!name.trim() || saving}
|
||||
style={{
|
||||
width: '100%', padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: name.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
border: 'none', background: name.trim() ? 'var(--text-primary)' : 'var(--border-faint)',
|
||||
color: name.trim() ? 'var(--bg-primary)' : 'var(--text-faint)', transition: 'all 0.15s',
|
||||
}}>
|
||||
{saving ? '...' : t('todo.detail.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Calendar, Camera, X, Clipboard } from 'lucide-react'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
|
||||
import { tripsApi, authApi } from '../../api/client'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -20,36 +23,68 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const fileRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
|
||||
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
|
||||
const can = useCanDo()
|
||||
const canUploadCover = !isEditing || can('trip_cover_upload', trip)
|
||||
const canEditTrip = !isEditing || can('trip_edit', trip)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reminder_days: 0 as number,
|
||||
day_count: 7,
|
||||
})
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [coverPreview, setCoverPreview] = useState(null)
|
||||
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
|
||||
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
||||
const [memberSelectValue, setMemberSelectValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (trip) {
|
||||
const rd = trip.reminder_days ?? 3
|
||||
setFormData({
|
||||
title: trip.title || '',
|
||||
description: trip.description || '',
|
||||
start_date: trip.start_date || '',
|
||||
end_date: trip.end_date || '',
|
||||
reminder_days: rd,
|
||||
day_count: trip.day_count || 7,
|
||||
})
|
||||
setCustomReminder(![0, 1, 3, 9].includes(rd))
|
||||
setCoverPreview(trip.cover_image || null)
|
||||
} else {
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 })
|
||||
setCustomReminder(false)
|
||||
setCoverPreview(null)
|
||||
}
|
||||
setPendingCoverFile(null)
|
||||
setSelectedMembers([])
|
||||
setError('')
|
||||
if (isOpen) {
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (!trip) {
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
}
|
||||
}, [trip, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trip && isOpen) {
|
||||
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
|
||||
}
|
||||
}, [tripRemindersEnabled])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -64,7 +99,18 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
description: formData.description.trim() || null,
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
reminder_days: formData.reminder_days,
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
||||
})
|
||||
// Add selected members for newly created trips
|
||||
if (selectedMembers.length > 0 && result?.trip?.id) {
|
||||
for (const userId of selectedMembers) {
|
||||
const user = allUsers.find(u => u.id === userId)
|
||||
if (user) {
|
||||
try { await tripsApi.addMember(result.trip.id, user.username) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Upload pending cover for newly created trips
|
||||
if (pendingCoverFile && result?.trip?.id) {
|
||||
try {
|
||||
@@ -135,6 +181,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
|
||||
// Paste support for cover image
|
||||
const handlePaste = (e) => {
|
||||
if (!canUploadCover) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -153,10 +200,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (!prev.end_date || prev.end_date < value) {
|
||||
next.end_date = value
|
||||
} else if (prev.start_date) {
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
|
||||
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
||||
const newEnd = new Date(value + 'T00:00:00')
|
||||
const newEnd = new Date(value + 'T00:00:00Z')
|
||||
newEnd.setDate(newEnd.getDate() + duration)
|
||||
next.end_date = newEnd.toISOString().split('T')[0]
|
||||
}
|
||||
@@ -192,8 +239,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Cover image — available for both create and edit */}
|
||||
<div>
|
||||
{/* Cover image — gated by trip_cover_upload permission */}
|
||||
{canUploadCover && <div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
||||
{coverPreview ? (
|
||||
@@ -212,26 +259,29 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
||||
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
|
||||
onDragOver={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.background = 'rgba(99,102,241,0.04)' }}
|
||||
onDragLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'none' }}
|
||||
onDrop={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'none'; const file = e.dataTransfer.files?.[0]; if (file?.type.startsWith('image/')) handleCoverSelect(file) }}
|
||||
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit', transition: 'all 0.15s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
||||
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
|
||||
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
||||
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
|
||||
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
|
||||
@@ -251,10 +301,110 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
|
||||
{!formData.start_date && !formData.end_date && (
|
||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||
{t('dashboard.noDateHint')}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('dashboard.dayCount')}
|
||||
</label>
|
||||
<input type="number" min={1} max={365} value={formData.day_count}
|
||||
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
||||
className={inputCls} />
|
||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reminder — only visible to owner (or when creating) */}
|
||||
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
|
||||
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<Bell className="inline w-4 h-4 mr-1" />{t('trips.reminder')}
|
||||
</label>
|
||||
{!tripRemindersEnabled ? (
|
||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||
{t('trips.reminderDisabledHint')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: 0, label: t('trips.reminderNone') },
|
||||
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
|
||||
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
|
||||
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
|
||||
].map(opt => (
|
||||
<button key={opt.value} type="button"
|
||||
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||
!customReminder && formData.reminder_days === opt.value
|
||||
? 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button"
|
||||
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||
customReminder
|
||||
? 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||
}`}>
|
||||
{t('trips.reminderCustom')}
|
||||
</button>
|
||||
</div>
|
||||
{customReminder && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="number" min={1} max={30}
|
||||
value={formData.reminder_days}
|
||||
onChange={e => update('reminder_days', Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||
className="w-20 px-3 py-1.5 border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
<span className="text-xs text-slate-500">{t('trips.reminderDaysBefore')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members — only for new trips */}
|
||||
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<UserPlus className="inline w-4 h-4 mr-1" />{t('dashboard.addMembers')}
|
||||
</label>
|
||||
{selectedMembers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{selectedMembers.map(uid => {
|
||||
const user = allUsers.find(u => u.id === uid)
|
||||
if (!user) return null
|
||||
return (
|
||||
<span key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
|
||||
background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', cursor: 'pointer',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}>
|
||||
{user.username}
|
||||
<X size={11} style={{ color: 'var(--text-faint)' }} />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<CustomSelect
|
||||
value={memberSelectValue}
|
||||
onChange={value => {
|
||||
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') }
|
||||
}}
|
||||
placeholder={t('dashboard.addMember')}
|
||||
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi } from '../../api/client'
|
||||
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -32,6 +34,129 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const [shareToken, setShareToken] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
shareApi.getLink(tripId).then(d => {
|
||||
setShareToken(d.token)
|
||||
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}, [tripId])
|
||||
|
||||
const shareUrl = shareToken ? `${window.location.origin}/shared/${shareToken}` : null
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const d = await shareApi.createLink(tripId, perms)
|
||||
setShareToken(d.token)
|
||||
} catch { toast.error(t('share.createError')) }
|
||||
}
|
||||
|
||||
const handleUpdatePerms = async (key: string, val: boolean) => {
|
||||
const newPerms = { ...perms, [key]: val }
|
||||
setPerms(newPerms)
|
||||
if (shareToken) {
|
||||
try { await shareApi.createLink(tripId, newPerms) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await shareApi.deleteLink(tripId)
|
||||
setShareToken(null)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
if (shareUrl) {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('share.linkTitle')}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
|
||||
|
||||
{/* Permission checkboxes */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{[
|
||||
{ key: 'share_map', label: t('share.permMap'), always: true },
|
||||
{ key: 'share_bookings', label: t('share.permBookings') },
|
||||
{ key: 'share_packing', label: t('share.permPacking') },
|
||||
{ key: 'share_budget', label: t('share.permBudget') },
|
||||
{ key: 'share_collab', label: t('share.permCollab') },
|
||||
].map(opt => (
|
||||
<button key={opt.key} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
|
||||
border: '1.5px solid', fontSize: 11, fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: perms[opt.key] ? 'var(--text-primary)' : 'transparent',
|
||||
borderColor: perms[opt.key] ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: perms[opt.key] ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
opacity: opt.always ? 0.7 : 1,
|
||||
}}>
|
||||
{perms[opt.key] ? <Check size={10} /> : null}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shareUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
||||
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<input type="text" value={shareUrl} readOnly style={{
|
||||
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
|
||||
outline: 'none', fontFamily: 'monospace',
|
||||
}} />
|
||||
<button onClick={handleCopy} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
|
||||
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||
}}>
|
||||
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleDelete} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={11} /> {t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleCreate} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={12} /> {t('share.createLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TripMembersModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
@@ -49,6 +174,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
const toast = useToast()
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canManageMembers = can('member_manage', trip)
|
||||
const canManageShare = can('share_manage', trip)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && tripId) {
|
||||
@@ -123,8 +252,12 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
] : []
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
{/* Left column: Members */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{/* Trip name */}
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
||||
@@ -133,7 +266,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</div>
|
||||
|
||||
{/* Add member dropdown */}
|
||||
<div>
|
||||
{canManageMembers && <div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
{t('members.inviteUser')}
|
||||
</label>
|
||||
@@ -166,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
||||
</button>
|
||||
</div>
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && (
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Members list */}
|
||||
<div>
|
||||
@@ -190,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{allMembers.map(member => {
|
||||
const isSelf = member.id === user?.id
|
||||
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
|
||||
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||
return (
|
||||
<div key={member.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
@@ -228,6 +361,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column: Share Link */}
|
||||
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||
<ShareLinkSection tripId={tripId} t={t} />
|
||||
</div>}
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function VacayCalendar() {
|
||||
}, [entries])
|
||||
|
||||
const blockWeekends = plan?.block_weekends !== false
|
||||
const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
|
||||
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
|
||||
|
||||
const handleCellClick = useCallback(async (dateStr) => {
|
||||
@@ -35,7 +36,7 @@ export default function VacayCalendar() {
|
||||
return
|
||||
}
|
||||
if (holidays[dateStr]) return
|
||||
if (blockWeekends && isWeekend(dateStr)) return
|
||||
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
|
||||
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||
await toggleEntry(dateStr, selectedUserId || undefined)
|
||||
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
||||
@@ -57,6 +58,7 @@ export default function VacayCalendar() {
|
||||
onCellClick={handleCellClick}
|
||||
companyMode={companyMode}
|
||||
blockWeekends={blockWeekends}
|
||||
weekendDays={weekendDays}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,14 @@ import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
import type { HolidaysMap, VacayEntry } from '../../types'
|
||||
|
||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
interface VacayMonthCardProps {
|
||||
year: number
|
||||
@@ -18,16 +22,18 @@ interface VacayMonthCardProps {
|
||||
onCellClick: (date: string) => void
|
||||
companyMode: boolean
|
||||
blockWeekends: boolean
|
||||
weekendDays?: number[]
|
||||
}
|
||||
|
||||
export default function VacayMonthCard({
|
||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||
onCellClick, companyMode, blockWeekends
|
||||
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
|
||||
}: VacayMonthCardProps) {
|
||||
const { language } = useTranslation()
|
||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
||||
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const weekdays = WEEKDAY_KEYS.map(k => t(k))
|
||||
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
@@ -47,7 +53,7 @@ export default function VacayMonthCard({
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)' }}>{monthNames[month]}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
@@ -65,7 +71,8 @@ export default function VacayMonthCard({
|
||||
if (day === null) return <div key={di} style={{ height: 28 }} />
|
||||
|
||||
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
|
||||
const weekend = di >= 5
|
||||
const dayOfWeek = new Date(year, month, day).getDay()
|
||||
const weekend = weekendDays.includes(dayOfWeek)
|
||||
const holiday = holidays[dateStr]
|
||||
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
||||
const dayEntries = entryMap[dateStr] || []
|
||||
@@ -74,6 +81,7 @@ export default function VacayMonthCard({
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
title={holiday ? (holiday.label ? `${holiday.label}: ${holiday.localName}` : holiday.localName) : undefined}
|
||||
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
||||
style={{
|
||||
height: 28,
|
||||
@@ -86,7 +94,7 @@ export default function VacayMonthCard({
|
||||
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
||||
>
|
||||
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
|
||||
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: hexToRgba(holiday.color, 0.12) }} />}
|
||||
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
||||
|
||||
{dayEntries.length === 1 && (
|
||||
@@ -115,7 +123,7 @@ export default function VacayMonthCard({
|
||||
)}
|
||||
|
||||
<span className="relative z-[1] text-[11px] font-medium" style={{
|
||||
color: holiday ? '#dc2626' : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
fontWeight: dayEntries.length > 0 ? 700 : 500,
|
||||
}}>
|
||||
{day}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import apiClient from '../../api/client'
|
||||
import type { VacayHolidayCalendar } from '../../types'
|
||||
|
||||
interface VacaySettingsProps {
|
||||
onClose: () => void
|
||||
@@ -13,10 +14,9 @@ interface VacaySettingsProps {
|
||||
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
|
||||
const [countries, setCountries] = useState([])
|
||||
const [regions, setRegions] = useState([])
|
||||
const [loadingRegions, setLoadingRegions] = useState(false)
|
||||
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
|
||||
const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
|
||||
const { language } = useTranslation()
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
useEffect(() => {
|
||||
apiClient.get('/addons/vacay/holidays/countries').then(r => {
|
||||
let displayNames
|
||||
try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
|
||||
try { displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
|
||||
const list = r.data.map(c => ({
|
||||
value: c.countryCode,
|
||||
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
|
||||
@@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
}).catch(() => {})
|
||||
}, [language])
|
||||
|
||||
// When country changes, check if it has regions
|
||||
const selectedCountry = plan?.holidays_region?.split('-')[0] || ''
|
||||
const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return }
|
||||
setLoadingRegions(true)
|
||||
const year = new Date().getFullYear()
|
||||
apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => {
|
||||
const allCounties = new Set()
|
||||
r.data.forEach(h => {
|
||||
if (h.counties) h.counties.forEach(c => allCounties.add(c))
|
||||
})
|
||||
if (allCounties.size > 0) {
|
||||
let subdivisionNames
|
||||
try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
|
||||
const regionList = [...allCounties].sort().map(c => {
|
||||
let label = c.split('-')[1] || c
|
||||
// Try Intl for full subdivision name (not all browsers support subdivision codes)
|
||||
// Fallback: use known mappings for DE
|
||||
if (c.startsWith('DE-')) {
|
||||
const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
|
||||
label = deRegions[c.split('-')[1]] || label
|
||||
} else if (c.startsWith('CH-')) {
|
||||
const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
|
||||
label = chRegions[c.split('-')[1]] || label
|
||||
}
|
||||
return { value: c, label }
|
||||
})
|
||||
setRegions(regionList)
|
||||
} else {
|
||||
setRegions([])
|
||||
// If no regions, just set country code as region
|
||||
if (plan.holidays_region !== selectedCountry) {
|
||||
updatePlan({ holidays_region: selectedCountry })
|
||||
}
|
||||
}
|
||||
}).catch(() => setRegions([])).finally(() => setLoadingRegions(false))
|
||||
}, [selectedCountry, plan?.holidays_enabled])
|
||||
|
||||
if (!plan) return null
|
||||
|
||||
const toggle = (key) => updatePlan({ [key]: !plan[key] })
|
||||
|
||||
const handleCountryChange = (countryCode) => {
|
||||
updatePlan({ holidays_region: countryCode })
|
||||
}
|
||||
|
||||
const handleRegionChange = (regionCode) => {
|
||||
updatePlan({ holidays_region: regionCode })
|
||||
}
|
||||
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -97,6 +49,42 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
onChange={() => toggle('block_weekends')}
|
||||
/>
|
||||
|
||||
{/* Weekend days selector */}
|
||||
{plan.block_weekends !== false && (
|
||||
<div style={{ paddingLeft: 36 }}>
|
||||
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
{ day: 1, label: t('vacay.mon') },
|
||||
{ day: 2, label: t('vacay.tue') },
|
||||
{ day: 3, label: t('vacay.wed') },
|
||||
{ day: 4, label: t('vacay.thu') },
|
||||
{ day: 5, label: t('vacay.fri') },
|
||||
{ day: 6, label: t('vacay.sat') },
|
||||
{ day: 0, label: t('vacay.sun') },
|
||||
].map(({ day, label }) => {
|
||||
const current: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
|
||||
const active = current.includes(day)
|
||||
return (
|
||||
<button key={day} onClick={() => {
|
||||
const next = active ? current.filter(d => d !== day) : [...current, day]
|
||||
updatePlan({ weekend_days: next.join(',') })
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
|
||||
background: active ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Carry-over */}
|
||||
<SettingToggle
|
||||
icon={ArrowRightLeft}
|
||||
@@ -136,21 +124,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
/>
|
||||
{plan.holidays_enabled && (
|
||||
<div className="ml-7 mt-2 space-y-2">
|
||||
<CustomSelect
|
||||
value={selectedCountry}
|
||||
onChange={handleCountryChange}
|
||||
options={countries}
|
||||
placeholder={t('vacay.selectCountry')}
|
||||
searchable
|
||||
/>
|
||||
{regions.length > 0 && (
|
||||
<CustomSelect
|
||||
value={selectedRegion}
|
||||
onChange={handleRegionChange}
|
||||
options={regions}
|
||||
placeholder={t('vacay.selectRegion')}
|
||||
searchable
|
||||
{(plan.holiday_calendars ?? []).length === 0 && (
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('vacay.noCalendars')}</p>
|
||||
)}
|
||||
{(plan.holiday_calendars ?? []).map(cal => (
|
||||
<CalendarRow
|
||||
key={cal.id}
|
||||
cal={cal}
|
||||
countries={countries}
|
||||
language={language}
|
||||
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
|
||||
onDelete={() => deleteHolidayCalendar(cal.id)}
|
||||
/>
|
||||
))}
|
||||
{showAddForm ? (
|
||||
<AddCalendarForm
|
||||
countries={countries}
|
||||
language={language}
|
||||
onAdd={async (data) => { await addHolidayCalendar(data); setShowAddForm(false) }}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors"
|
||||
style={{ color: 'var(--text-muted)', background: 'var(--bg-secondary)' }}
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t('vacay.addCalendar')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -197,11 +199,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
}
|
||||
|
||||
interface SettingToggleProps {
|
||||
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
hint: string
|
||||
value: boolean
|
||||
onChange: (value: boolean) => void
|
||||
onChange: () => void
|
||||
}
|
||||
|
||||
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
||||
@@ -223,3 +225,202 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── shared region-loading helper ─────────────────────────────────────────────
|
||||
async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
|
||||
try {
|
||||
const year = new Date().getFullYear()
|
||||
const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`)
|
||||
const allCounties = new Set<string>()
|
||||
r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) })
|
||||
if (allCounties.size === 0) return []
|
||||
return [...allCounties].sort().map(c => {
|
||||
let label = c.split('-')[1] || c
|
||||
if (c.startsWith('DE-')) {
|
||||
const m: Record<string, string> = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
|
||||
label = m[c.split('-')[1]] || label
|
||||
} else if (c.startsWith('CH-')) {
|
||||
const m: Record<string, string> = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
|
||||
label = m[c.split('-')[1]] || label
|
||||
}
|
||||
return { value: c, label }
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ── Existing calendar row (inline edit) ──────────────────────────────────────
|
||||
function CalendarRow({ cal, countries, onUpdate, onDelete }: {
|
||||
cal: VacayHolidayCalendar
|
||||
countries: { value: string; label: string }[]
|
||||
language: string
|
||||
onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [localColor, setLocalColor] = useState(cal.color)
|
||||
const [localLabel, setLocalLabel] = useState(cal.label || '')
|
||||
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
|
||||
|
||||
const selectedCountry = cal.region.split('-')[0]
|
||||
const selectedRegion = cal.region.includes('-') ? cal.region : ''
|
||||
|
||||
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
|
||||
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) { setRegions([]); return }
|
||||
fetchRegionOptions(selectedCountry).then(setRegions)
|
||||
}, [selectedCountry])
|
||||
|
||||
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-start p-3 rounded-xl" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
style={{ width: 28, height: 28, borderRadius: 8, background: localColor, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
|
||||
title={t('vacay.calendarColor')}
|
||||
/>
|
||||
{showColorPicker && (
|
||||
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button key={c} onClick={() => { setLocalColor(c); setShowColorPicker(false); if (c !== cal.color) onUpdate({ color: c }) }}
|
||||
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: localColor === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={localLabel}
|
||||
onChange={e => setLocalLabel(e.target.value)}
|
||||
onBlur={() => { const v = localLabel.trim() || null; if (v !== cal.label) onUpdate({ label: v }) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
placeholder={t('vacay.calendarLabel')}
|
||||
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
/>
|
||||
<CustomSelect
|
||||
value={selectedCountry}
|
||||
onChange={v => onUpdate({ region: v })}
|
||||
options={countries}
|
||||
placeholder={t('vacay.selectCountry')}
|
||||
searchable
|
||||
/>
|
||||
{regions.length > 0 && (
|
||||
<CustomSelect
|
||||
value={selectedRegion}
|
||||
onChange={v => onUpdate({ region: v })}
|
||||
options={regions}
|
||||
placeholder={t('vacay.selectRegion')}
|
||||
searchable
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="shrink-0 p-1.5 rounded-md transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'rgba(239,68,68,0.1)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add-new-calendar form ─────────────────────────────────────────────────────
|
||||
function AddCalendarForm({ countries, onAdd, onCancel }: {
|
||||
countries: { value: string; label: string }[]
|
||||
language: string
|
||||
onAdd: (data: { region: string; color: string; label: string | null }) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [region, setRegion] = useState('')
|
||||
const [color, setColor] = useState('#fecaca')
|
||||
const [label, setLabel] = useState('')
|
||||
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
|
||||
const [loadingRegions, setLoadingRegions] = useState(false)
|
||||
|
||||
const selectedCountry = region.split('-')[0] || ''
|
||||
const selectedRegion = region.includes('-') ? region : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) { setRegions([]); return }
|
||||
setLoadingRegions(true)
|
||||
fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
|
||||
}, [selectedCountry])
|
||||
|
||||
const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
|
||||
|
||||
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-start p-3 rounded-xl border border-dashed" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
style={{ width: 28, height: 28, borderRadius: 8, background: color, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
|
||||
title={t('vacay.calendarColor')}
|
||||
/>
|
||||
{showColorPicker && (
|
||||
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button key={c} onClick={() => { setColor(c); setShowColorPicker(false) }}
|
||||
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: color === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder={t('vacay.calendarLabel')}
|
||||
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
/>
|
||||
<CustomSelect
|
||||
value={selectedCountry}
|
||||
onChange={v => { setRegion(v); setRegions([]) }}
|
||||
options={countries}
|
||||
placeholder={t('vacay.selectCountry')}
|
||||
searchable
|
||||
/>
|
||||
{regions.length > 0 && (
|
||||
<CustomSelect
|
||||
value={selectedRegion}
|
||||
onChange={v => setRegion(v)}
|
||||
options={regions}
|
||||
placeholder={t('vacay.selectRegion')}
|
||||
searchable
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-1.5 pt-0.5">
|
||||
<button
|
||||
disabled={!canAdd}
|
||||
onClick={() => onAdd({ region: region || selectedCountry, color, label: label.trim() || null })}
|
||||
className="flex-1 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
|
||||
>
|
||||
{t('vacay.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-xs px-2 py-1.5 rounded-md transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,29 +103,28 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
|
||||
return holidays
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr: string): boolean {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
const day = d.getDay()
|
||||
return day === 0 || day === 6
|
||||
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return weekendDays.includes(d.getUTCDay())
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getUTCDay()]
|
||||
}
|
||||
|
||||
export function getWeekdayFull(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getUTCDay()]
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
export function formatDate(dateStr: string, locale?: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER }
|
||||
|
||||
@@ -11,17 +11,19 @@ interface CustomDatePickerProps {
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
compact?: boolean
|
||||
borderless?: boolean
|
||||
}
|
||||
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {}, compact = false, borderless = false }: CustomDatePickerProps) {
|
||||
const { locale, t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const parsed = value ? new Date(value + 'T00:00:00') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
||||
const parsed = value ? new Date(value + 'T00:00:00Z') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -34,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
|
||||
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
|
||||
}, [open])
|
||||
|
||||
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
|
||||
@@ -45,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
|
||||
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
|
||||
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null
|
||||
|
||||
const selectDay = (day: number) => {
|
||||
const y = String(viewYear)
|
||||
@@ -55,26 +57,61 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
|
||||
const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null
|
||||
const today = new Date()
|
||||
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||
|
||||
const [textInput, setTextInput] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
|
||||
const handleTextSubmit = () => {
|
||||
setIsTyping(false)
|
||||
if (!textInput.trim()) return
|
||||
// Try to parse various date formats
|
||||
const input = textInput.trim()
|
||||
// ISO: 2026-03-29
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { onChange(input); return }
|
||||
// EU: 29.03.2026 or 29/03/2026
|
||||
const euMatch = input.match(/^(\d{1,2})[./](\d{1,2})[./](\d{2,4})$/)
|
||||
if (euMatch) {
|
||||
const y = euMatch[3].length === 2 ? 2000 + parseInt(euMatch[3]) : parseInt(euMatch[3])
|
||||
onChange(`${y}-${String(euMatch[2]).padStart(2, '0')}-${String(euMatch[1]).padStart(2, '0')}`)
|
||||
return
|
||||
}
|
||||
// Try native Date parse as fallback
|
||||
const d = new Date(input)
|
||||
if (!isNaN(d.getTime())) {
|
||||
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
{isTyping ? (
|
||||
<input autoFocus type="text" value={textInput} onChange={e => setTextInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleTextSubmit(); if (e.key === 'Escape') setIsTyping(false) }}
|
||||
onBlur={handleTextSubmit}
|
||||
placeholder="DD.MM.YYYY"
|
||||
style={{
|
||||
width: '100%', padding: '8px 14px', borderRadius: 10, border: '1px solid var(--text-faint)',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none',
|
||||
}} />
|
||||
) : (
|
||||
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: compact ? 4 : 8,
|
||||
padding: compact ? '4px 6px' : '8px 14px', borderRadius: compact ? 4 : 10,
|
||||
border: borderless ? 'none' : '1px solid var(--border-primary)',
|
||||
background: borderless ? 'transparent' : 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
{!compact && <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />}
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CustomSelectProps {
|
||||
searchable?: boolean
|
||||
style?: React.CSSProperties
|
||||
size?: 'sm' | 'md'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
@@ -29,6 +30,7 @@ export default function CustomSelect({
|
||||
searchable = false,
|
||||
style = {},
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
}: CustomSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -83,17 +85,19 @@ export default function CustomSelect({
|
||||
{/* Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(o => !o); setSearch('') }}
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
||||
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
||||
cursor: disabled ? 'default' : 'pointer', outline: 'none', textAlign: 'left',
|
||||
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
||||
@@ -107,9 +111,15 @@ export default function CustomSelect({
|
||||
{open && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed',
|
||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
||||
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
|
||||
...(() => {
|
||||
const r = ref.current?.getBoundingClientRect()
|
||||
if (!r) return { top: 0, left: 0, width: 200 }
|
||||
const spaceBelow = window.innerHeight - r.bottom
|
||||
const openUp = spaceBelow < 220 && r.top > spaceBelow
|
||||
return openUp
|
||||
? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width }
|
||||
: { top: r.bottom + 4, left: r.left, width: r.width }
|
||||
})(),
|
||||
zIndex: 99999,
|
||||
background: 'var(--bg-card)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value
|
||||
onChange(raw)
|
||||
if (is12h) return // let handleBlur parse 12h formats
|
||||
const clean = raw.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
||||
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
||||
@@ -80,7 +81,23 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!value) return
|
||||
const clean = value.replace(/[^0-9:]/g, '')
|
||||
const raw = value.trim()
|
||||
|
||||
// Parse 12h input like "5:30 PM", "5:30pm", "530pm"
|
||||
if (is12h) {
|
||||
const match12 = raw.match(/^(\d{1,2}):?(\d{2})?\s*(am|pm)$/i)
|
||||
if (match12) {
|
||||
let h = parseInt(match12[1])
|
||||
const m = match12[2] ? parseInt(match12[2]) : 0
|
||||
const isPm = match12[3].toLowerCase() === 'pm'
|
||||
if (h === 12) h = isPm ? 12 : 0
|
||||
else if (isPm) h += 12
|
||||
onChange(String(Math.min(23, h)).padStart(2, '0') + ':' + String(Math.min(59, m)).padStart(2, '0'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const clean = raw.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||
const [hh, mm] = clean.split(':')
|
||||
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
||||
|
||||
@@ -7,6 +7,7 @@ const sizeClasses: Record<string, string> = {
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-2xl',
|
||||
'2xl': 'max-w-4xl',
|
||||
'3xl': 'max-w-5xl',
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface Category {
|
||||
@@ -14,48 +14,52 @@ interface PlaceAvatarProps {
|
||||
category?: Category | null
|
||||
}
|
||||
|
||||
const photoCache = new Map<string, string | null>()
|
||||
const photoInFlight = new Set<string>()
|
||||
|
||||
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Observe visibility — fetch photo only when avatar enters viewport
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setVisible(true); return }
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
// Check if already cached — show immediately without waiting for intersection
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||
if (cacheKey && getCached(cacheKey)) { setVisible(true); return }
|
||||
|
||||
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [place.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
||||
|
||||
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||
if (photoCache.has(cacheKey)) {
|
||||
const cached = photoCache.get(cacheKey)
|
||||
if (cached) setPhotoSrc(cached)
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached) {
|
||||
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
|
||||
if (!cached.thumbDataUrl && cached.photoUrl) {
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (photoInFlight.has(cacheKey)) {
|
||||
// Another instance is already fetching, wait for it
|
||||
const check = setInterval(() => {
|
||||
if (photoCache.has(cacheKey)) {
|
||||
clearInterval(check)
|
||||
const cached = photoCache.get(cacheKey)
|
||||
if (cached) setPhotoSrc(cached)
|
||||
}
|
||||
}, 200)
|
||||
return () => clearInterval(check)
|
||||
if (isLoading(cacheKey)) {
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}
|
||||
photoInFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then((data: { photoUrl?: string }) => {
|
||||
if (data.photoUrl) {
|
||||
photoCache.set(cacheKey, data.photoUrl)
|
||||
setPhotoSrc(data.photoUrl)
|
||||
} else {
|
||||
photoCache.set(cacheKey, null)
|
||||
}
|
||||
photoInFlight.delete(cacheKey)
|
||||
})
|
||||
.catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
|
||||
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
|
||||
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||
)
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||
|
||||
const bgColor = category?.color || '#6366f1'
|
||||
const IconComp = getCategoryIcon(category?.icon)
|
||||
@@ -72,10 +76,11 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
||||
|
||||
if (photoSrc) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div ref={ref} style={containerStyle}>
|
||||
<img
|
||||
src={photoSrc}
|
||||
alt={place.name}
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
/>
|
||||
@@ -84,8 +89,8 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div ref={ref} style={containerStyle}>
|
||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
|
||||
|
||||
export function useInAppNotificationListener(): void {
|
||||
const handleNew = useInAppNotificationStore(s => s.handleNewNotification)
|
||||
const handleUpdated = useInAppNotificationStore(s => s.handleUpdatedNotification)
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Record<string, unknown>) => {
|
||||
if (event.type === 'notification:new') {
|
||||
handleNew(event.notification as any)
|
||||
} else if (event.type === 'notification:updated') {
|
||||
handleUpdated(event.notification as any)
|
||||
}
|
||||
}
|
||||
addListener(listener)
|
||||
return () => removeListener(listener)
|
||||
}, [handleNew, handleUpdated])
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRef, useReducer } from 'react'
|
||||
|
||||
export interface UndoEntry {
|
||||
label: string
|
||||
undo: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function usePlannerHistory(maxEntries = 30) {
|
||||
const historyRef = useRef<UndoEntry[]>([])
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)
|
||||
|
||||
const pushUndo = (label: string, undoFn: () => Promise<void> | void) => {
|
||||
historyRef.current = [{ label, undo: undoFn }, ...historyRef.current].slice(0, maxEntries)
|
||||
forceUpdate()
|
||||
}
|
||||
|
||||
const undo = async () => {
|
||||
if (historyRef.current.length === 0) return
|
||||
const [first, ...rest] = historyRef.current
|
||||
historyRef.current = rest
|
||||
forceUpdate()
|
||||
try { await first.undo() } catch (e) { console.error('Undo failed:', e) }
|
||||
}
|
||||
|
||||
const canUndo = historyRef.current.length > 0
|
||||
const lastActionLabel = historyRef.current[0]?.label ?? null
|
||||
|
||||
return { pushUndo, undo, canUndo, lastActionLabel }
|
||||
}
|
||||
@@ -15,11 +15,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
|
||||
const tripStoreRef = useRef(tripStore)
|
||||
tripStoreRef.current = tripStore
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const currentAssignments = tripStoreRef.current.assignments || {}
|
||||
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
||||
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
||||
@@ -33,12 +37,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||
else if (!(err instanceof Error)) setRouteSegments([])
|
||||
}
|
||||
}, [tripStore, routeCalcEnabled])
|
||||
}, [routeCalcEnabled])
|
||||
|
||||
// Only recalculate when assignments for the SELECTED day change
|
||||
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
|
||||
useEffect(() => {
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
}, [selectedDayId, tripStore.assignments])
|
||||
}, [selectedDayId, selectedDayAssignments])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,55 @@
|
||||
import React, { createContext, useContext, useMemo, ReactNode } from 'react'
|
||||
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import de from './translations/de'
|
||||
import en from './translations/en'
|
||||
import es from './translations/es'
|
||||
import fr from './translations/fr'
|
||||
import hu from './translations/hu'
|
||||
import it from './translations/it'
|
||||
import ru from './translations/ru'
|
||||
import zh from './translations/zh'
|
||||
import zhTw from './translations/zhTw'
|
||||
import nl from './translations/nl'
|
||||
import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
import cs from './translations/cs'
|
||||
import pl from './translations/pl'
|
||||
|
||||
type TranslationStrings = Record<string, string>
|
||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en }
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'hu', label: 'Magyar' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'br', label: 'Português (Brasil)' },
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
{ value: 'zh-TW', label: '繁體中文' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
] as const
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', 'zh-TW': 'zh-TW', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ', pl: 'pl-PL' }
|
||||
const RTL_LANGUAGES = new Set(['ar'])
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
return LOCALES[language] || LOCALES.en
|
||||
}
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
if (language === 'br') return 'pt-BR'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl'].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
export function isRtlLanguage(language: string): boolean {
|
||||
return RTL_LANGUAGES.has(language)
|
||||
}
|
||||
|
||||
interface TranslationContextValue {
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
@@ -13,21 +57,26 @@ interface TranslationContextValue {
|
||||
locale: string
|
||||
}
|
||||
|
||||
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'de', locale: 'de-DE' })
|
||||
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'en', locale: 'en-US' })
|
||||
|
||||
interface TranslationProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
const language = useSettingsStore((s) => s.settings.language) || 'de'
|
||||
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
||||
}, [language])
|
||||
|
||||
const value = useMemo((): TranslationContextValue => {
|
||||
const strings = translations[language] || translations.de
|
||||
const fallback = translations.de
|
||||
const strings = translations[language] || translations.en
|
||||
const fallback = translations.en
|
||||
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
let val: string = strings[key] ?? fallback[key] ?? key
|
||||
let val: string = (strings[key] ?? fallback[key] ?? key) as string
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||
@@ -36,7 +85,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
return val
|
||||
}
|
||||
|
||||
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
|
||||
return { t, language, locale: getLocaleForLanguage(language) }
|
||||
}, [language])
|
||||
|
||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export { TranslationProvider, useTranslation } from './TranslationContext'
|
||||
export {
|
||||
TranslationProvider,
|
||||
useTranslation,
|
||||
getLocaleForLanguage,
|
||||
getIntlLanguage,
|
||||
isRtlLanguage,
|
||||
SUPPORTED_LANGUAGES,
|
||||
} from './TranslationContext'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
const de: Record<string, string> = {
|
||||
const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Allgemein
|
||||
'common.save': 'Speichern',
|
||||
'common.cancel': 'Abbrechen',
|
||||
@@ -6,6 +6,7 @@ const de: Record<string, string> = {
|
||||
'common.edit': 'Bearbeiten',
|
||||
'common.add': 'Hinzufügen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importieren',
|
||||
'common.error': 'Fehler',
|
||||
'common.back': 'Zurück',
|
||||
'common.all': 'Alle',
|
||||
@@ -25,6 +26,14 @@ const de: Record<string, string> = {
|
||||
'common.email': 'E-Mail',
|
||||
'common.password': 'Passwort',
|
||||
'common.saving': 'Speichern...',
|
||||
'common.saved': 'Gespeichert',
|
||||
'trips.reminder': 'Erinnerung',
|
||||
'trips.reminderNone': 'Keine',
|
||||
'trips.reminderDay': 'Tag',
|
||||
'trips.reminderDays': 'Tage',
|
||||
'trips.reminderCustom': 'Benutzerdefiniert',
|
||||
'trips.reminderDaysBefore': 'Tage vor Abreise',
|
||||
'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.',
|
||||
'common.update': 'Aktualisieren',
|
||||
'common.change': 'Ändern',
|
||||
'common.uploading': 'Hochladen…',
|
||||
@@ -51,9 +60,18 @@ const de: Record<string, string> = {
|
||||
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
|
||||
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
|
||||
'dashboard.newTrip': 'Neue Reise',
|
||||
'dashboard.gridView': 'Kachelansicht',
|
||||
'dashboard.listView': 'Listenansicht',
|
||||
'dashboard.currency': 'Währung',
|
||||
'dashboard.timezone': 'Zeitzonen',
|
||||
'dashboard.localTime': 'Lokal',
|
||||
'dashboard.timezoneCustomTitle': 'Eigene Zeitzone',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Bezeichnung (optional)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'z.B. America/New_York',
|
||||
'dashboard.timezoneCustomAdd': 'Hinzufügen',
|
||||
'dashboard.timezoneCustomErrorEmpty': 'Zeitzone eingeben',
|
||||
'dashboard.timezoneCustomErrorInvalid': 'Ungültige Zeitzone. Format: Europe/Berlin',
|
||||
'dashboard.timezoneCustomErrorDuplicate': 'Bereits hinzugefügt',
|
||||
'dashboard.emptyTitle': 'Noch keine Reisen',
|
||||
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
|
||||
'dashboard.emptyButton': 'Erste Reise erstellen',
|
||||
@@ -62,7 +80,10 @@ const de: Record<string, string> = {
|
||||
'dashboard.sharedBy': 'Geteilt von {name}',
|
||||
'dashboard.days': 'Tage',
|
||||
'dashboard.places': 'Orte',
|
||||
'dashboard.members': 'Reise-Buddies',
|
||||
'dashboard.archive': 'Archivieren',
|
||||
'dashboard.copyTrip': 'Kopieren',
|
||||
'dashboard.copySuffix': 'Kopie',
|
||||
'dashboard.restore': 'Wiederherstellen',
|
||||
'dashboard.archived': 'Archiviert',
|
||||
'dashboard.status.ongoing': 'Laufend',
|
||||
@@ -81,6 +102,8 @@ const de: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': 'Fehler beim Archivieren',
|
||||
'dashboard.toast.restored': 'Reise wiederhergestellt',
|
||||
'dashboard.toast.restoreError': 'Fehler beim Wiederherstellen',
|
||||
'dashboard.toast.copied': 'Reise kopiert!',
|
||||
'dashboard.toast.copyError': 'Fehler beim Kopieren der Reise',
|
||||
'dashboard.confirm.delete': 'Reise "{title}" löschen? Alle Orte und Pläne werden unwiderruflich gelöscht.',
|
||||
'dashboard.editTrip': 'Reise bearbeiten',
|
||||
'dashboard.createTrip': 'Neue Reise erstellen',
|
||||
@@ -90,9 +113,13 @@ const de: Record<string, string> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'Worum geht es bei dieser Reise?',
|
||||
'dashboard.startDate': 'Startdatum',
|
||||
'dashboard.endDate': 'Enddatum',
|
||||
'dashboard.dayCount': 'Anzahl Tage',
|
||||
'dashboard.dayCountHint': 'Wie viele Tage geplant werden sollen, wenn kein Reisezeitraum gesetzt ist.',
|
||||
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
|
||||
'dashboard.coverImage': 'Titelbild',
|
||||
'dashboard.addCoverImage': 'Titelbild hinzufügen',
|
||||
'dashboard.addCoverImage': 'Titelbild hinzufügen (oder per Drag & Drop)',
|
||||
'dashboard.addMembers': 'Reisebegleiter',
|
||||
'dashboard.addMember': 'Mitglied hinzufügen',
|
||||
'dashboard.coverSaved': 'Titelbild gespeichert',
|
||||
'dashboard.coverUploadError': 'Fehler beim Hochladen',
|
||||
'dashboard.coverRemoveError': 'Fehler beim Entfernen',
|
||||
@@ -102,6 +129,12 @@ const de: Record<string, string> = {
|
||||
// Settings
|
||||
'settings.title': 'Einstellungen',
|
||||
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
||||
'settings.tabs.display': 'Anzeige',
|
||||
'settings.tabs.map': 'Karte',
|
||||
'settings.tabs.notifications': 'Benachrichtigungen',
|
||||
'settings.tabs.integrations': 'Integrationen',
|
||||
'settings.tabs.account': 'Konto',
|
||||
'settings.tabs.about': 'Über',
|
||||
'settings.map': 'Karte',
|
||||
'settings.mapTemplate': 'Karten-Vorlage',
|
||||
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
|
||||
@@ -128,15 +161,111 @@ const de: Record<string, string> = {
|
||||
'settings.temperature': 'Temperatureinheit',
|
||||
'settings.timeFormat': 'Zeitformat',
|
||||
'settings.routeCalculation': 'Routenberechnung',
|
||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||
'settings.notifications': 'Benachrichtigungen',
|
||||
'settings.notifyTripInvite': 'Trip-Einladungen',
|
||||
'settings.notifyBookingChange': 'Buchungsänderungen',
|
||||
'settings.notifyTripReminder': 'Trip-Erinnerungen',
|
||||
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
|
||||
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
|
||||
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
|
||||
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
|
||||
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
|
||||
'settings.notificationsDisabled': 'Benachrichtigungen sind nicht konfiguriert. Bitten Sie einen Administrator, E-Mail- oder Webhook-Benachrichtungen zu aktivieren.',
|
||||
'settings.notificationsActive': 'Aktiver Kanal',
|
||||
'settings.notificationsManagedByAdmin': 'Benachrichtigungsereignisse werden vom Administrator konfiguriert.',
|
||||
'admin.notifications.title': 'Benachrichtigungen',
|
||||
'admin.notifications.hint': 'Wählen Sie einen Benachrichtigungskanal. Es kann nur einer gleichzeitig aktiv sein.',
|
||||
'admin.notifications.none': 'Deaktiviert',
|
||||
'admin.notifications.email': 'E-Mail (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Benachrichtigungsereignisse',
|
||||
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
|
||||
'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.',
|
||||
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
|
||||
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
|
||||
'admin.notifications.testWebhook': 'Test-Webhook senden',
|
||||
'admin.notifications.testWebhookSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||
'admin.notifications.testWebhookFailed': 'Test-Webhook fehlgeschlagen',
|
||||
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
|
||||
'admin.smtp.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
|
||||
'admin.smtp.testButton': 'Test-E-Mail senden',
|
||||
'admin.webhook.hint': 'Benachrichtigungen an einen externen Webhook senden (Discord, Slack usw.).',
|
||||
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
|
||||
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
|
||||
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
|
||||
'share.linkTitle': 'Öffentlicher Link',
|
||||
'share.linkHint': 'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen — keine Bearbeitung möglich.',
|
||||
'share.createLink': 'Link erstellen',
|
||||
'share.deleteLink': 'Link löschen',
|
||||
'share.createError': 'Link konnte nicht erstellt werden',
|
||||
'common.copy': 'Kopieren',
|
||||
'common.copied': 'Kopiert',
|
||||
'share.permMap': 'Karte & Plan',
|
||||
'share.permBookings': 'Buchungen',
|
||||
'share.permPacking': 'Packliste',
|
||||
'shared.expired': 'Link abgelaufen oder ungültig',
|
||||
'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.',
|
||||
'shared.readOnly': 'Nur-Lesen Ansicht',
|
||||
'shared.tabPlan': 'Plan',
|
||||
'shared.tabBookings': 'Buchungen',
|
||||
'shared.tabPacking': 'Packliste',
|
||||
'shared.tabBudget': 'Budget',
|
||||
'shared.tabChat': 'Chat',
|
||||
'shared.days': 'Tage',
|
||||
'shared.places': 'Orte',
|
||||
'shared.other': 'Sonstige',
|
||||
'shared.totalBudget': 'Gesamtbudget',
|
||||
'shared.messages': 'Nachrichten',
|
||||
'shared.sharedVia': 'Geteilt über',
|
||||
'shared.confirmed': 'Bestätigt',
|
||||
'shared.pending': 'Ausstehend',
|
||||
'share.permBudget': 'Budget',
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'An',
|
||||
'settings.off': 'Aus',
|
||||
'settings.mcp.title': 'MCP-Konfiguration',
|
||||
'settings.mcp.endpoint': 'MCP-Endpunkt',
|
||||
'settings.mcp.clientConfig': 'Client-Konfiguration',
|
||||
'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
|
||||
'settings.mcp.copy': 'Kopieren',
|
||||
'settings.mcp.copied': 'Kopiert!',
|
||||
'settings.mcp.apiTokens': 'API-Tokens',
|
||||
'settings.mcp.createToken': 'Neuen Token erstellen',
|
||||
'settings.mcp.noTokens': 'Noch keine Tokens. Erstelle einen, um MCP-Clients zu verbinden.',
|
||||
'settings.mcp.tokenCreatedAt': 'Erstellt',
|
||||
'settings.mcp.tokenUsedAt': 'Verwendet',
|
||||
'settings.mcp.deleteTokenTitle': 'Token löschen',
|
||||
'settings.mcp.deleteTokenMessage': 'Dieser Token wird sofort ungültig. Jeder MCP-Client, der ihn verwendet, verliert den Zugang.',
|
||||
'settings.mcp.modal.createTitle': 'API-Token erstellen',
|
||||
'settings.mcp.modal.tokenName': 'Token-Name',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'z. B. Claude Desktop, Arbeits-Laptop',
|
||||
'settings.mcp.modal.creating': 'Wird erstellt…',
|
||||
'settings.mcp.modal.create': 'Token erstellen',
|
||||
'settings.mcp.modal.createdTitle': 'Token erstellt',
|
||||
'settings.mcp.modal.createdWarning': 'Dieser Token wird nur einmal angezeigt. Kopiere und speichere ihn jetzt — er kann nicht wiederhergestellt werden.',
|
||||
'settings.mcp.modal.done': 'Fertig',
|
||||
'settings.mcp.toast.created': 'Token erstellt',
|
||||
'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden',
|
||||
'settings.mcp.toast.deleted': 'Token gelöscht',
|
||||
'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden',
|
||||
'settings.account': 'Konto',
|
||||
'settings.about': 'Über',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
'settings.about.reportBugHint': 'Problem gefunden? Melde es uns',
|
||||
'settings.about.featureRequest': 'Feature vorschlagen',
|
||||
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
|
||||
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
|
||||
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
|
||||
'settings.about.madeWith': 'Entwickelt mit',
|
||||
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
|
||||
'settings.username': 'Benutzername',
|
||||
'settings.email': 'E-Mail',
|
||||
'settings.role': 'Rolle',
|
||||
'settings.roleAdmin': 'Administrator',
|
||||
'settings.oidcLinked': 'Verknüpft mit',
|
||||
'settings.changePassword': 'Passwort ändern',
|
||||
'settings.mustChangePassword': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können. Bitte legen Sie unten ein neues Passwort fest.',
|
||||
'settings.currentPassword': 'Aktuelles Passwort',
|
||||
'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt',
|
||||
'settings.newPassword': 'Neues Passwort',
|
||||
@@ -145,7 +274,7 @@ const de: Record<string, string> = {
|
||||
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
|
||||
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten',
|
||||
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten',
|
||||
'settings.passwordChanged': 'Passwort erfolgreich geändert',
|
||||
'settings.deleteAccount': 'Löschen',
|
||||
'settings.deleteAccountTitle': 'Account wirklich löschen?',
|
||||
@@ -164,6 +293,30 @@ const de: Record<string, string> = {
|
||||
'settings.avatarUploaded': 'Profilbild aktualisiert',
|
||||
'settings.avatarRemoved': 'Profilbild entfernt',
|
||||
'settings.avatarError': 'Fehler beim Hochladen',
|
||||
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
|
||||
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
|
||||
'settings.mfa.requiredByPolicy': 'Dein Administrator verlangt Zwei-Faktor-Authentifizierung. Richte unten eine Authenticator-App ein, bevor du fortfährst.',
|
||||
'settings.mfa.backupTitle': 'Backup-Codes',
|
||||
'settings.mfa.backupDescription': 'Verwende diese Einmal-Codes, wenn du keinen Zugriff mehr auf deine Authenticator-App hast.',
|
||||
'settings.mfa.backupWarning': 'Jetzt speichern. Jeder Code kann nur einmal verwendet werden.',
|
||||
'settings.mfa.backupCopy': 'Codes kopieren',
|
||||
'settings.mfa.backupDownload': 'TXT herunterladen',
|
||||
'settings.mfa.backupPrint': 'Drucken / PDF',
|
||||
'settings.mfa.backupCopied': 'Backup-Codes kopiert',
|
||||
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
|
||||
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
|
||||
'settings.mfa.setup': 'Authenticator einrichten',
|
||||
'settings.mfa.scanQr': 'QR-Code mit der App scannen oder den Schlüssel manuell eingeben.',
|
||||
'settings.mfa.secretLabel': 'Geheimer Schlüssel (manuell)',
|
||||
'settings.mfa.codePlaceholder': '6-stelliger Code',
|
||||
'settings.mfa.enable': '2FA aktivieren',
|
||||
'settings.mfa.cancelSetup': 'Abbrechen',
|
||||
'settings.mfa.disableTitle': '2FA deaktivieren',
|
||||
'settings.mfa.disableHint': 'Passwort und einen aktuellen Code aus der Authenticator-App eingeben.',
|
||||
'settings.mfa.disable': '2FA deaktivieren',
|
||||
'settings.mfa.toastEnabled': 'Zwei-Faktor-Authentifizierung aktiviert',
|
||||
'settings.mfa.toastDisabled': 'Zwei-Faktor-Authentifizierung deaktiviert',
|
||||
'settings.mfa.demoBlocked': 'In der Demo nicht verfügbar',
|
||||
|
||||
// Login
|
||||
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
|
||||
@@ -192,6 +345,8 @@ const de: Record<string, string> = {
|
||||
'login.signIn': 'Anmelden',
|
||||
'login.createAdmin': 'Admin-Konto erstellen',
|
||||
'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.',
|
||||
'login.setNewPassword': 'Neues Passwort festlegen',
|
||||
'login.setNewPasswordHint': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können.',
|
||||
'login.createAccount': 'Konto erstellen',
|
||||
'login.createAccountHint': 'Neues Konto registrieren.',
|
||||
'login.creating': 'Erstelle…',
|
||||
@@ -206,11 +361,19 @@ const de: Record<string, string> = {
|
||||
'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.',
|
||||
'login.demoFailed': 'Demo-Login fehlgeschlagen',
|
||||
'login.oidcSignIn': 'Anmelden mit {name}',
|
||||
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
|
||||
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
|
||||
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
|
||||
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
|
||||
'login.mfaCodeLabel': 'Bestätigungscode',
|
||||
'login.mfaCodeRequired': 'Bitte den Code aus der Authenticator-App eingeben.',
|
||||
'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.',
|
||||
'login.mfaBack': '← Zurück zur Anmeldung',
|
||||
'login.mfaVerify': 'Bestätigen',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||
'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||
'register.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||
'register.failed': 'Registrierung fehlgeschlagen',
|
||||
'register.getStarted': 'Jetzt starten',
|
||||
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',
|
||||
@@ -236,6 +399,7 @@ const de: Record<string, string> = {
|
||||
'admin.tabs.users': 'Benutzer',
|
||||
'admin.tabs.categories': 'Kategorien',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
'admin.stats.users': 'Benutzer',
|
||||
'admin.stats.trips': 'Reisen',
|
||||
'admin.stats.places': 'Orte',
|
||||
@@ -264,9 +428,29 @@ const de: Record<string, string> = {
|
||||
'admin.toast.createError': 'Fehler beim Erstellen des Benutzers',
|
||||
'admin.toast.fieldsRequired': 'Benutzername, E-Mail und Passwort sind erforderlich',
|
||||
'admin.createUser': 'Benutzer anlegen',
|
||||
'admin.invite.title': 'Einladungslinks',
|
||||
'admin.invite.subtitle': 'Einmal-Links für die Registrierung erstellen',
|
||||
'admin.invite.create': 'Link erstellen',
|
||||
'admin.invite.createAndCopy': 'Erstellen & kopieren',
|
||||
'admin.invite.empty': 'Noch keine Einladungslinks erstellt',
|
||||
'admin.invite.maxUses': 'Max. Nutzungen',
|
||||
'admin.invite.expiry': 'Gültig für',
|
||||
'admin.invite.uses': 'genutzt',
|
||||
'admin.invite.expiresAt': 'läuft ab am',
|
||||
'admin.invite.createdBy': 'von',
|
||||
'admin.invite.active': 'Aktiv',
|
||||
'admin.invite.expired': 'Abgelaufen',
|
||||
'admin.invite.usedUp': 'Aufgebraucht',
|
||||
'admin.invite.copied': 'Einladungslink in Zwischenablage kopiert',
|
||||
'admin.invite.copyLink': 'Link kopieren',
|
||||
'admin.invite.deleted': 'Einladungslink gelöscht',
|
||||
'admin.invite.createError': 'Fehler beim Erstellen des Einladungslinks',
|
||||
'admin.invite.deleteError': 'Fehler beim Löschen des Einladungslinks',
|
||||
'admin.tabs.settings': 'Einstellungen',
|
||||
'admin.allowRegistration': 'Registrierung erlauben',
|
||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||
'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen',
|
||||
'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.',
|
||||
'admin.apiKeys': 'API-Schlüssel',
|
||||
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
|
||||
'admin.mapsKey': 'Google Maps API Key',
|
||||
@@ -285,6 +469,8 @@ const de: Record<string, string> = {
|
||||
'admin.oidcIssuer': 'Issuer URL',
|
||||
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
|
||||
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
|
||||
'admin.oidcOnlyMode': 'Passwort-Authentifizierung deaktivieren',
|
||||
'admin.oidcOnlyModeHint': 'Wenn aktiviert, ist nur SSO-Login erlaubt. Passwort-Login und Registrierung werden blockiert.',
|
||||
|
||||
// File Types
|
||||
'admin.fileTypes': 'Erlaubte Dateitypen',
|
||||
@@ -292,18 +478,59 @@ const de: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
||||
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Gepäck-Tracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
||||
'admin.tabs.config': 'Personalisierung',
|
||||
'admin.tabs.templates': 'Packvorlagen',
|
||||
'admin.packingTemplates.title': 'Packvorlagen',
|
||||
'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen',
|
||||
'admin.packingTemplates.create': 'Neue Vorlage',
|
||||
'admin.packingTemplates.namePlaceholder': 'Vorlagenname (z.B. Strandurlaub)',
|
||||
'admin.packingTemplates.empty': 'Noch keine Vorlagen erstellt',
|
||||
'admin.packingTemplates.items': 'Einträge',
|
||||
'admin.packingTemplates.categories': 'Kategorien',
|
||||
'admin.packingTemplates.itemName': 'Artikelname',
|
||||
'admin.packingTemplates.itemCategory': 'Kategorie',
|
||||
'admin.packingTemplates.categoryName': 'Kategoriename (z.B. Kleidung)',
|
||||
'admin.packingTemplates.addCategory': 'Kategorie hinzufügen',
|
||||
'admin.packingTemplates.created': 'Vorlage erstellt',
|
||||
'admin.packingTemplates.deleted': 'Vorlage gelöscht',
|
||||
'admin.packingTemplates.loadError': 'Vorlagen konnten nicht geladen werden',
|
||||
'admin.packingTemplates.createError': 'Vorlage konnte nicht erstellt werden',
|
||||
'admin.packingTemplates.deleteError': 'Vorlage konnte nicht gelöscht werden',
|
||||
'admin.packingTemplates.saveError': 'Fehler beim Speichern',
|
||||
|
||||
// Addons
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
|
||||
'admin.addons.catalog.packing.name': 'Listen',
|
||||
'admin.addons.catalog.packing.description': 'Packlisten und To-Do-Aufgaben für deine Reisen',
|
||||
'admin.addons.catalog.budget.name': 'Budget',
|
||||
'admin.addons.catalog.budget.description': 'Ausgaben verfolgen und Reisebudget planen',
|
||||
'admin.addons.catalog.documents.name': 'Dokumente',
|
||||
'admin.addons.catalog.documents.description': 'Reisedokumente speichern und verwalten',
|
||||
'admin.addons.catalog.vacay.name': 'Vacay',
|
||||
'admin.addons.catalog.vacay.description': 'Persönlicher Urlaubsplaner mit Kalenderansicht',
|
||||
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
|
||||
'admin.addons.catalog.collab.name': 'Collab',
|
||||
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol für die KI-Assistenten-Integration',
|
||||
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
||||
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
||||
'admin.addons.enabled': 'Aktiviert',
|
||||
'admin.addons.disabled': 'Deaktiviert',
|
||||
'admin.addons.type.trip': 'Trip',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.integration': 'Integration',
|
||||
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
|
||||
'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation',
|
||||
'admin.addons.integrationHint': 'Backend-Dienste und API-Integrationen ohne eigene Seite',
|
||||
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
||||
@@ -319,8 +546,37 @@ const de: Record<string, string> = {
|
||||
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
|
||||
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'MCP-Tokens',
|
||||
'admin.mcpTokens.title': 'MCP-Tokens',
|
||||
'admin.mcpTokens.subtitle': 'API-Tokens aller Benutzer verwalten',
|
||||
'admin.mcpTokens.owner': 'Besitzer',
|
||||
'admin.mcpTokens.tokenName': 'Token-Name',
|
||||
'admin.mcpTokens.created': 'Erstellt',
|
||||
'admin.mcpTokens.lastUsed': 'Zuletzt verwendet',
|
||||
'admin.mcpTokens.never': 'Nie',
|
||||
'admin.mcpTokens.empty': 'Es wurden noch keine MCP-Tokens erstellt',
|
||||
'admin.mcpTokens.deleteTitle': 'Token löschen',
|
||||
'admin.mcpTokens.deleteMessage': 'Dieser Token wird sofort widerrufen. Der Benutzer verliert den MCP-Zugang über diesen Token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token gelöscht',
|
||||
'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden',
|
||||
'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).',
|
||||
'admin.audit.empty': 'Noch keine Audit-Einträge.',
|
||||
'admin.audit.refresh': 'Aktualisieren',
|
||||
'admin.audit.loadMore': 'Mehr laden',
|
||||
'admin.audit.showing': '{count} geladen · {total} gesamt',
|
||||
'admin.audit.col.time': 'Zeit',
|
||||
'admin.audit.col.user': 'Benutzer',
|
||||
'admin.audit.col.action': 'Aktion',
|
||||
'admin.audit.col.resource': 'Ressource',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Details',
|
||||
|
||||
'admin.github.title': 'Update-Verlauf',
|
||||
'admin.github.subtitle': 'Neueste Updates von {repo}',
|
||||
'admin.github.latest': 'Aktuell',
|
||||
@@ -331,6 +587,7 @@ const de: Record<string, string> = {
|
||||
'admin.github.loading': 'Wird geladen...',
|
||||
'admin.github.error': 'Releases konnten nicht geladen werden',
|
||||
'admin.github.by': 'von',
|
||||
'admin.github.support': 'Hilft mir, TREK weiterzuentwickeln',
|
||||
|
||||
'admin.update.available': 'Update verfügbar',
|
||||
'admin.update.text': 'TREK {version} ist verfügbar. Du verwendest {current}.',
|
||||
@@ -354,7 +611,8 @@ const de: Record<string, string> = {
|
||||
'vacay.subtitle': 'Urlaubstage planen und verwalten',
|
||||
'vacay.settings': 'Einstellungen',
|
||||
'vacay.year': 'Jahr',
|
||||
'vacay.addYear': 'Jahr hinzufügen',
|
||||
'vacay.addYear': 'Nächstes Jahr hinzufügen',
|
||||
'vacay.addPrevYear': 'Vorheriges Jahr hinzufügen',
|
||||
'vacay.removeYear': 'Jahr entfernen',
|
||||
'vacay.removeYearConfirm': '{year} entfernen?',
|
||||
'vacay.removeYearHint': 'Alle Urlaubseinträge und Betriebsferien für dieses Jahr werden unwiderruflich gelöscht.',
|
||||
@@ -382,11 +640,23 @@ const de: Record<string, string> = {
|
||||
'vacay.remaining': 'Rest',
|
||||
'vacay.carriedOver': 'aus {year}',
|
||||
'vacay.blockWeekends': 'Wochenenden sperren',
|
||||
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen',
|
||||
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Wochenendtagen',
|
||||
'vacay.weekendDays': 'Wochenendtage',
|
||||
'vacay.mon': 'Mo',
|
||||
'vacay.tue': 'Di',
|
||||
'vacay.wed': 'Mi',
|
||||
'vacay.thu': 'Do',
|
||||
'vacay.fri': 'Fr',
|
||||
'vacay.sat': 'Sa',
|
||||
'vacay.sun': 'So',
|
||||
'vacay.publicHolidays': 'Feiertage',
|
||||
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
||||
'vacay.selectCountry': 'Land wählen',
|
||||
'vacay.selectRegion': 'Region wählen (optional)',
|
||||
'vacay.addCalendar': 'Kalender hinzufügen',
|
||||
'vacay.calendarLabel': 'Bezeichnung (optional)',
|
||||
'vacay.calendarColor': 'Farbe',
|
||||
'vacay.noCalendars': 'Noch keine Feiertagskalender angelegt',
|
||||
'vacay.companyHolidays': 'Betriebsferien',
|
||||
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
|
||||
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
|
||||
@@ -431,6 +701,27 @@ const de: Record<string, string> = {
|
||||
'atlas.countries': 'Länder',
|
||||
'atlas.trips': 'Reisen',
|
||||
'atlas.places': 'Orte',
|
||||
'atlas.unmark': 'Entfernen',
|
||||
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
|
||||
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
|
||||
'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?',
|
||||
'atlas.markVisited': 'Als besucht markieren',
|
||||
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
||||
'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen',
|
||||
'atlas.addToBucket': 'Zur Bucket List',
|
||||
'atlas.addPoi': 'Ort hinzufügen',
|
||||
'atlas.searchCountry': 'Land suchen...',
|
||||
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
|
||||
'atlas.month': 'Monat',
|
||||
'atlas.year': 'Jahr',
|
||||
'atlas.addToBucketHint': 'Als Wunschziel speichern',
|
||||
'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?',
|
||||
'atlas.statsTab': 'Statistik',
|
||||
'atlas.bucketTab': 'Bucket List',
|
||||
'atlas.addBucket': 'Zur Bucket List hinzufügen',
|
||||
'atlas.bucketNotesPlaceholder': 'Notizen (optional)',
|
||||
'atlas.bucketEmpty': 'Deine Bucket List ist leer',
|
||||
'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest',
|
||||
'atlas.days': 'Tage',
|
||||
'atlas.visitedCountries': 'Besuchte Länder',
|
||||
'atlas.cities': 'Städte',
|
||||
@@ -440,7 +731,6 @@ const de: Record<string, string> = {
|
||||
'atlas.nextTrip': 'Nächster Trip',
|
||||
'atlas.daysLeft': 'Tage',
|
||||
'atlas.streak': 'Streak',
|
||||
'atlas.year': 'Jahr',
|
||||
'atlas.years': 'Jahre',
|
||||
'atlas.yearInRow': 'Jahr in Folge',
|
||||
'atlas.yearsInRow': 'Jahre in Folge',
|
||||
@@ -467,9 +757,12 @@ const de: Record<string, string> = {
|
||||
'trip.tabs.reservationsShort': 'Buchung',
|
||||
'trip.tabs.packing': 'Liste',
|
||||
'trip.tabs.packingShort': 'Liste',
|
||||
'trip.tabs.lists': 'Listen',
|
||||
'trip.tabs.listsShort': 'Listen',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Dateien',
|
||||
'trip.loading': 'Reise wird geladen...',
|
||||
'trip.loadingPhotos': 'Fotos der Orte werden geladen...',
|
||||
'trip.mobilePlan': 'Planung',
|
||||
'trip.mobilePlaces': 'Orte',
|
||||
'trip.toast.placeUpdated': 'Ort aktualisiert',
|
||||
@@ -485,6 +778,12 @@ const de: Record<string, string> = {
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
||||
'dayplan.cannotReorderTransport': 'Buchungen mit fester Uhrzeit können nicht verschoben werden',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Uhrzeit entfernen?',
|
||||
'dayplan.confirmRemoveTimeBody': 'Dieser Ort hat eine feste Uhrzeit ({time}). Durch das Verschieben wird die Uhrzeit entfernt und der Ort kann frei sortiert werden.',
|
||||
'dayplan.confirmRemoveTimeAction': 'Uhrzeit entfernen & verschieben',
|
||||
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
|
||||
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
|
||||
'dayplan.addNote': 'Notiz hinzufügen',
|
||||
'dayplan.editNote': 'Notiz bearbeiten',
|
||||
'dayplan.noteAdd': 'Notiz hinzufügen',
|
||||
@@ -510,11 +809,22 @@ const de: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||
'places.urlResolved': 'Ort aus URL importiert',
|
||||
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
||||
'places.importGoogleList': 'Google Liste',
|
||||
'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
|
||||
'places.googleListImported': '{count} Orte aus "{list}" importiert',
|
||||
'places.googleListError': 'Google Maps Liste konnte nicht importiert werden',
|
||||
'places.viewDetails': 'Details anzeigen',
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.search': 'Orte suchen...',
|
||||
'places.allCategories': 'Alle Kategorien',
|
||||
'places.categoriesSelected': 'Kategorien',
|
||||
'places.clearFilter': 'Filter zurücksetzen',
|
||||
'places.count': '{count} Orte',
|
||||
'places.countSingular': '1 Ort',
|
||||
'places.allPlanned': 'Alle Orte sind eingeplant',
|
||||
@@ -563,6 +873,7 @@ const de: Record<string, string> = {
|
||||
'inspector.addRes': 'Reservierung',
|
||||
'inspector.editRes': 'Reservierung bearbeiten',
|
||||
'inspector.participants': 'Teilnehmer',
|
||||
'inspector.trackStats': 'Streckendaten',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Buchungen',
|
||||
@@ -598,13 +909,13 @@ const de: Record<string, string> = {
|
||||
'reservations.meta.linkAccommodation': 'Unterkunft',
|
||||
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
|
||||
'reservations.meta.noAccommodation': 'Keine',
|
||||
'reservations.meta.hotelPlace': 'Hotel',
|
||||
'reservations.meta.pickHotel': 'Hotel auswählen',
|
||||
'reservations.meta.hotelPlace': 'Unterkunft',
|
||||
'reservations.meta.pickHotel': 'Unterkunft auswählen',
|
||||
'reservations.meta.fromDay': 'Von',
|
||||
'reservations.meta.toDay': 'Bis',
|
||||
'reservations.meta.selectDay': 'Tag wählen',
|
||||
'reservations.type.flight': 'Flug',
|
||||
'reservations.type.hotel': 'Hotel',
|
||||
'reservations.type.hotel': 'Unterkunft',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Zug',
|
||||
'reservations.type.car': 'Mietwagen',
|
||||
@@ -613,6 +924,8 @@ const de: Record<string, string> = {
|
||||
'reservations.type.tour': 'Tour',
|
||||
'reservations.type.other': 'Sonstiges',
|
||||
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
|
||||
'reservations.confirm.deleteTitle': 'Buchung löschen?',
|
||||
'reservations.confirm.deleteBody': '"{name}" wird unwiderruflich gelöscht.',
|
||||
'reservations.toast.updated': 'Reservierung aktualisiert',
|
||||
'reservations.toast.removed': 'Reservierung gelöscht',
|
||||
'reservations.toast.saveError': 'Fehler beim Speichern',
|
||||
@@ -636,12 +949,40 @@ const de: Record<string, string> = {
|
||||
'reservations.pendingSave': 'wird gespeichert…',
|
||||
'reservations.uploading': 'Wird hochgeladen...',
|
||||
'reservations.attachFile': 'Datei anhängen',
|
||||
'reservations.linkExisting': 'Vorhandene verknüpfen',
|
||||
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
|
||||
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
|
||||
'reservations.noAssignment': 'Keine Verknüpfung',
|
||||
'reservations.price': 'Preis',
|
||||
'reservations.budgetCategory': 'Budgetkategorie',
|
||||
'reservations.budgetCategoryPlaceholder': 'z.B. Transport, Unterkunft',
|
||||
'reservations.budgetCategoryAuto': 'Auto (aus Buchungstyp)',
|
||||
'reservations.budgetHint': 'Beim Speichern wird automatisch ein Budgeteintrag erstellt.',
|
||||
'reservations.departureDate': 'Abflug',
|
||||
'reservations.arrivalDate': 'Ankunft',
|
||||
'reservations.departureTime': 'Abflugzeit',
|
||||
'reservations.arrivalTime': 'Ankunftszeit',
|
||||
'reservations.pickupDate': 'Abholung',
|
||||
'reservations.returnDate': 'Rückgabe',
|
||||
'reservations.pickupTime': 'Abholzeit',
|
||||
'reservations.returnTime': 'Rückgabezeit',
|
||||
'reservations.endDate': 'Enddatum',
|
||||
'reservations.meta.departureTimezone': 'Abfl. TZ',
|
||||
'reservations.meta.arrivalTimezone': 'Ank. TZ',
|
||||
'reservations.span.departure': 'Abflug',
|
||||
'reservations.span.arrival': 'Ankunft',
|
||||
'reservations.span.inTransit': 'Unterwegs',
|
||||
'reservations.span.pickup': 'Abholung',
|
||||
'reservations.span.return': 'Rückgabe',
|
||||
'reservations.span.active': 'Aktiv',
|
||||
'reservations.span.start': 'Start',
|
||||
'reservations.span.end': 'Ende',
|
||||
'reservations.span.ongoing': 'Laufend',
|
||||
'reservations.validation.endBeforeStart': 'Enddatum/-zeit muss nach dem Startdatum/-zeit liegen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.exportCsv': 'CSV exportieren',
|
||||
'budget.emptyTitle': 'Noch kein Budget erstellt',
|
||||
'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen',
|
||||
'budget.emptyPlaceholder': 'Kategoriename eingeben...',
|
||||
@@ -656,6 +997,7 @@ const de: Record<string, string> = {
|
||||
'budget.table.perDay': 'Pro Tag',
|
||||
'budget.table.perPersonDay': 'P. p / Tag',
|
||||
'budget.table.note': 'Notiz',
|
||||
'budget.table.date': 'Datum',
|
||||
'budget.newEntry': 'Neuer Eintrag',
|
||||
'budget.defaultEntry': 'Neuer Eintrag',
|
||||
'budget.defaultCategory': 'Neue Kategorie',
|
||||
@@ -663,12 +1005,16 @@ const de: Record<string, string> = {
|
||||
'budget.totalBudget': 'Gesamtbudget',
|
||||
'budget.byCategory': 'Nach Kategorie',
|
||||
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
||||
'budget.linkedToReservation': 'Verknüpft mit einer Buchung — Name dort bearbeiten',
|
||||
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
|
||||
'budget.deleteCategory': 'Kategorie löschen',
|
||||
'budget.perPerson': 'Pro Person',
|
||||
'budget.paid': 'Bezahlt',
|
||||
'budget.open': 'Offen',
|
||||
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
|
||||
'budget.settlement': 'Ausgleich',
|
||||
'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
|
||||
'budget.netBalances': 'Netto-Salden',
|
||||
|
||||
// Files
|
||||
'files.title': 'Dateien',
|
||||
@@ -722,6 +1068,15 @@ const de: Record<string, string> = {
|
||||
// Packing
|
||||
'packing.title': 'Packliste',
|
||||
'packing.empty': 'Packliste ist leer',
|
||||
'packing.import': 'Importieren',
|
||||
'packing.importTitle': 'Packliste importieren',
|
||||
'packing.importHint': 'Ein Eintrag pro Zeile. Format: Kategorie, Name, Gewicht in g (optional), Tasche (optional), checked/unchecked (optional)',
|
||||
'packing.importPlaceholder': 'Hygiene, Zahnbürste\nKleidung, T-Shirts, 200\nDokumente, Reisepass, , Handgepäck\nElektronik, Ladekabel, 50, Koffer, checked',
|
||||
'packing.importCsv': 'CSV/TXT laden',
|
||||
'packing.importAction': '{count} importieren',
|
||||
'packing.importSuccess': '{count} Einträge importiert',
|
||||
'packing.importError': 'Import fehlgeschlagen',
|
||||
'packing.importEmpty': 'Keine Einträge zum Importieren',
|
||||
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
|
||||
'packing.clearChecked': '{count} abgehakte entfernen',
|
||||
'packing.clearCheckedShort': '{count} entfernen',
|
||||
@@ -741,6 +1096,25 @@ const de: Record<string, string> = {
|
||||
'packing.menuCheckAll': 'Alle abhaken',
|
||||
'packing.menuUncheckAll': 'Alle Haken entfernen',
|
||||
'packing.menuDeleteCat': 'Kategorie löschen',
|
||||
'packing.assignUser': 'Benutzer zuweisen',
|
||||
'packing.noMembers': 'Keine Mitglieder',
|
||||
'packing.addItem': 'Eintrag hinzufügen',
|
||||
'packing.addItemPlaceholder': 'Artikelname...',
|
||||
'packing.addCategory': 'Kategorie hinzufügen',
|
||||
'packing.newCategoryPlaceholder': 'Kategoriename (z.B. Kleidung)',
|
||||
'packing.applyTemplate': 'Vorlage anwenden',
|
||||
'packing.template': 'Vorlage',
|
||||
'packing.templateApplied': '{count} Einträge aus Vorlage hinzugefügt',
|
||||
'packing.templateError': 'Vorlage konnte nicht angewendet werden',
|
||||
'packing.saveAsTemplate': 'Als Vorlage speichern',
|
||||
'packing.templateName': 'Vorlagenname',
|
||||
'packing.templateSaved': 'Packliste als Vorlage gespeichert',
|
||||
'packing.assignUser': 'Person zuweisen',
|
||||
'packing.bags': 'Gepäck',
|
||||
'packing.noBag': 'Nicht zugeordnet',
|
||||
'packing.totalWeight': 'Gesamtgewicht',
|
||||
'packing.bagName': 'Name...',
|
||||
'packing.addBag': 'Gepäck hinzufügen',
|
||||
'packing.changeCategory': 'Kategorie ändern',
|
||||
'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?',
|
||||
'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?',
|
||||
@@ -858,7 +1232,27 @@ const de: Record<string, string> = {
|
||||
'backup.auto.enable': 'Auto-Backup aktivieren',
|
||||
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
|
||||
'backup.auto.interval': 'Intervall',
|
||||
'backup.auto.hour': 'Ausführung um',
|
||||
'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
|
||||
'backup.auto.dayOfWeek': 'Wochentag',
|
||||
'backup.auto.dayOfMonth': 'Tag des Monats',
|
||||
'backup.auto.dayOfMonthHint': 'Auf 1–28 beschränkt, um mit allen Monaten kompatibel zu sein',
|
||||
'backup.auto.scheduleSummary': 'Zeitplan',
|
||||
'backup.auto.summaryDaily': 'Täglich um {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
|
||||
'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
|
||||
'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
|
||||
'backup.auto.keepLabel': 'Alte Backups löschen nach',
|
||||
'backup.dow.sunday': 'So',
|
||||
'backup.dow.monday': 'Mo',
|
||||
'backup.dow.tuesday': 'Di',
|
||||
'backup.dow.wednesday': 'Mi',
|
||||
'backup.dow.thursday': 'Do',
|
||||
'backup.dow.friday': 'Fr',
|
||||
'backup.dow.saturday': 'Sa',
|
||||
'backup.interval.hourly': 'Stündlich',
|
||||
'backup.interval.daily': 'Täglich',
|
||||
'backup.interval.weekly': 'Wöchentlich',
|
||||
@@ -985,6 +1379,52 @@ const de: Record<string, string> = {
|
||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||
'day.reservations': 'Reservierungen',
|
||||
|
||||
// Photos / Immich
|
||||
'memories.title': 'Fotos',
|
||||
'memories.notConnected': 'Immich nicht verbunden',
|
||||
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
|
||||
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
|
||||
'memories.noPhotos': 'Keine Fotos gefunden',
|
||||
'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
|
||||
'memories.photosFound': 'Fotos',
|
||||
'memories.fromOthers': 'von anderen',
|
||||
'memories.sharePhotos': 'Fotos teilen',
|
||||
'memories.sharing': 'Wird geteilt',
|
||||
'memories.reviewTitle': 'Deine Fotos prüfen',
|
||||
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
|
||||
'memories.shareCount': '{count} Fotos teilen',
|
||||
'memories.immichUrl': 'Immich Server URL',
|
||||
'memories.immichApiKey': 'API-Schlüssel',
|
||||
'memories.testConnection': 'Verbindung testen',
|
||||
'memories.testFirst': 'Verbindung zuerst testen',
|
||||
'memories.connected': 'Verbunden',
|
||||
'memories.disconnected': 'Nicht verbunden',
|
||||
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
|
||||
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
|
||||
'memories.saved': 'Immich-Einstellungen gespeichert',
|
||||
'memories.addPhotos': 'Fotos hinzufügen',
|
||||
'memories.linkAlbum': 'Album verknüpfen',
|
||||
'memories.selectAlbum': 'Immich-Album auswählen',
|
||||
'memories.noAlbums': 'Keine Alben gefunden',
|
||||
'memories.syncAlbum': 'Album synchronisieren',
|
||||
'memories.unlinkAlbum': 'Album trennen',
|
||||
'memories.photos': 'Fotos',
|
||||
'memories.selectPhotos': 'Fotos aus Immich auswählen',
|
||||
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
||||
'memories.selected': 'ausgewählt',
|
||||
'memories.addSelected': '{count} Fotos hinzufügen',
|
||||
'memories.alreadyAdded': 'Hinzugefügt',
|
||||
'memories.private': 'Privat',
|
||||
'memories.stopSharing': 'Nicht mehr teilen',
|
||||
'memories.oldest': 'Älteste zuerst',
|
||||
'memories.newest': 'Neueste zuerst',
|
||||
'memories.allLocations': 'Alle Orte',
|
||||
'memories.tripDates': 'Trip-Zeitraum',
|
||||
'memories.allPhotos': 'Alle Fotos',
|
||||
'memories.confirmShareTitle': 'Mit Reisebegleitern teilen?',
|
||||
'memories.confirmShareHint': '{count} Fotos werden für alle Mitglieder dieses Trips sichtbar. Du kannst einzelne Fotos nachträglich auf privat setzen.',
|
||||
'memories.confirmShareButton': 'Fotos teilen',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notizen',
|
||||
@@ -1003,6 +1443,7 @@ const de: Record<string, string> = {
|
||||
'collab.chat.today': 'Heute',
|
||||
'collab.chat.yesterday': 'Gestern',
|
||||
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
|
||||
'collab.chat.reply': 'Antworten',
|
||||
'collab.chat.loadMore': 'Ältere Nachrichten laden',
|
||||
'collab.chat.justNow': 'gerade eben',
|
||||
'collab.chat.minutesAgo': 'vor {n} Min.',
|
||||
@@ -1053,6 +1494,204 @@ const de: Record<string, string> = {
|
||||
'collab.polls.options': 'Optionen',
|
||||
'collab.polls.delete': 'Löschen',
|
||||
'collab.polls.closedSection': 'Geschlossen',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Berechtigungen',
|
||||
'perm.title': 'Berechtigungseinstellungen',
|
||||
'perm.subtitle': 'Steuern Sie, wer Aktionen in der Anwendung ausführen kann',
|
||||
'perm.saved': 'Berechtigungseinstellungen gespeichert',
|
||||
'perm.resetDefaults': 'Auf Standard zurücksetzen',
|
||||
'perm.customized': 'angepasst',
|
||||
'perm.level.admin': 'Nur Administrator',
|
||||
'perm.level.tripOwner': 'Reise-Eigentümer',
|
||||
'perm.level.tripMember': 'Reise-Mitglieder',
|
||||
'perm.level.everybody': 'Alle',
|
||||
'perm.cat.trip': 'Reiseverwaltung',
|
||||
'perm.cat.members': 'Mitgliederverwaltung',
|
||||
'perm.cat.files': 'Dateien',
|
||||
'perm.cat.content': 'Inhalte & Zeitplan',
|
||||
'perm.cat.extras': 'Budget, Packlisten & Zusammenarbeit',
|
||||
'perm.action.trip_create': 'Reisen erstellen',
|
||||
'perm.action.trip_edit': 'Reisedetails bearbeiten',
|
||||
'perm.action.trip_delete': 'Reisen löschen',
|
||||
'perm.action.trip_archive': 'Reisen archivieren / dearchivieren',
|
||||
'perm.action.trip_cover_upload': 'Titelbild hochladen',
|
||||
'perm.action.member_manage': 'Mitglieder hinzufügen / entfernen',
|
||||
'perm.action.file_upload': 'Dateien hochladen',
|
||||
'perm.action.file_edit': 'Datei-Metadaten bearbeiten',
|
||||
'perm.action.file_delete': 'Dateien löschen',
|
||||
'perm.action.place_edit': 'Orte hinzufügen / bearbeiten / löschen',
|
||||
'perm.action.day_edit': 'Tage, Notizen & Zuweisungen bearbeiten',
|
||||
'perm.action.reservation_edit': 'Reservierungen verwalten',
|
||||
'perm.action.budget_edit': 'Budget verwalten',
|
||||
'perm.action.packing_edit': 'Packlisten verwalten',
|
||||
'perm.action.collab_edit': 'Zusammenarbeit (Notizen, Umfragen, Chat)',
|
||||
'perm.action.share_manage': 'Freigabelinks verwalten',
|
||||
'perm.actionHint.trip_create': 'Wer kann neue Reisen erstellen',
|
||||
'perm.actionHint.trip_edit': 'Wer kann Reisename, Daten, Beschreibung und Währung ändern',
|
||||
'perm.actionHint.trip_delete': 'Wer kann eine Reise dauerhaft löschen',
|
||||
'perm.actionHint.trip_archive': 'Wer kann eine Reise archivieren oder dearchivieren',
|
||||
'perm.actionHint.trip_cover_upload': 'Wer kann das Titelbild hochladen oder ändern',
|
||||
'perm.actionHint.member_manage': 'Wer kann Reise-Mitglieder einladen oder entfernen',
|
||||
'perm.actionHint.file_upload': 'Wer kann Dateien zu einer Reise hochladen',
|
||||
'perm.actionHint.file_edit': 'Wer kann Dateibeschreibungen und Links bearbeiten',
|
||||
'perm.actionHint.file_delete': 'Wer kann Dateien in den Papierkorb verschieben oder dauerhaft löschen',
|
||||
'perm.actionHint.place_edit': 'Wer kann Orte hinzufügen, bearbeiten oder löschen',
|
||||
'perm.actionHint.day_edit': 'Wer kann Tage, Tagesnotizen und Ort-Zuweisungen bearbeiten',
|
||||
'perm.actionHint.reservation_edit': 'Wer kann Reservierungen erstellen, bearbeiten oder löschen',
|
||||
'perm.actionHint.budget_edit': 'Wer kann Budgetposten erstellen, bearbeiten oder löschen',
|
||||
'perm.actionHint.packing_edit': 'Wer kann Packstücke und Taschen verwalten',
|
||||
'perm.actionHint.collab_edit': 'Wer kann Notizen, Umfragen erstellen und Nachrichten senden',
|
||||
'perm.actionHint.share_manage': 'Wer kann öffentliche Freigabelinks erstellen oder löschen',
|
||||
// Undo
|
||||
'undo.button': 'Rückgängig',
|
||||
'undo.tooltip': 'Rückgängig: {action}',
|
||||
'undo.assignPlace': 'Ort einem Tag zugewiesen',
|
||||
'undo.removeAssignment': 'Ort von Tag entfernt',
|
||||
'undo.reorder': 'Orte neu sortiert',
|
||||
'undo.optimize': 'Route optimiert',
|
||||
'undo.deletePlace': 'Ort gelöscht',
|
||||
'undo.moveDay': 'Ort zu anderem Tag verschoben',
|
||||
'undo.lock': 'Ortssperre umgeschaltet',
|
||||
'undo.importGpx': 'GPX-Import',
|
||||
'undo.importGoogleList': 'Google Maps-Import',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Benachrichtigungen',
|
||||
'notifications.markAllRead': 'Alle als gelesen markieren',
|
||||
'notifications.deleteAll': 'Alle löschen',
|
||||
'notifications.showAll': 'Alle Benachrichtigungen anzeigen',
|
||||
'notifications.empty': 'Keine Benachrichtigungen',
|
||||
'notifications.emptyDescription': 'Sie sind auf dem neuesten Stand!',
|
||||
'notifications.all': 'Alle',
|
||||
'notifications.unreadOnly': 'Ungelesen',
|
||||
'notifications.markRead': 'Als gelesen markieren',
|
||||
'notifications.markUnread': 'Als ungelesen markieren',
|
||||
'notifications.delete': 'Löschen',
|
||||
'notifications.system': 'System',
|
||||
'memories.error.loadAlbums': 'Alben konnten nicht geladen werden',
|
||||
'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden',
|
||||
'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden',
|
||||
'memories.error.syncAlbum': 'Album konnte nicht synchronisiert werden',
|
||||
'memories.error.loadPhotos': 'Fotos konnten nicht geladen werden',
|
||||
'memories.error.addPhotos': 'Fotos konnten nicht hinzugefügt werden',
|
||||
'memories.error.removePhoto': 'Foto konnte nicht entfernt werden',
|
||||
'memories.error.toggleSharing': 'Freigabe konnte nicht aktualisiert werden',
|
||||
'undo.addPlace': 'Ort hinzugefügt',
|
||||
'undo.done': 'Rückgängig gemacht: {action}',
|
||||
'notifications.test.title': 'Testbenachrichtigung von {actor}',
|
||||
'notifications.test.text': 'Dies ist eine einfache Testbenachrichtigung.',
|
||||
'notifications.test.booleanTitle': '{actor} bittet um Ihre Zustimmung',
|
||||
'notifications.test.booleanText': 'Dies ist eine boolesche Testbenachrichtigung.',
|
||||
'notifications.test.accept': 'Genehmigen',
|
||||
'notifications.test.decline': 'Ablehnen',
|
||||
'notifications.test.navigateTitle': 'Etwas ansehen',
|
||||
'notifications.test.navigateText': 'Dies ist eine Navigations-Testbenachrichtigung.',
|
||||
'notifications.test.goThere': 'Dorthin',
|
||||
'notifications.test.adminTitle': 'Admin-Broadcast',
|
||||
'notifications.test.adminText': '{actor} hat eine Testbenachrichtigung an alle Admins gesendet.',
|
||||
'notifications.test.tripTitle': '{actor} hat in Ihrer Reise gepostet',
|
||||
'notifications.test.tripText': 'Testbenachrichtigung für Reise "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Packliste',
|
||||
'todo.subtab.todo': 'To-Do',
|
||||
'todo.completed': 'erledigt',
|
||||
'todo.filter.all': 'Alle',
|
||||
'todo.filter.open': 'Offen',
|
||||
'todo.filter.done': 'Erledigt',
|
||||
'todo.uncategorized': 'Ohne Kategorie',
|
||||
'todo.namePlaceholder': 'Aufgabenname',
|
||||
'todo.descriptionPlaceholder': 'Beschreibung (optional)',
|
||||
'todo.unassigned': 'Nicht zugewiesen',
|
||||
'todo.noCategory': 'Keine Kategorie',
|
||||
'todo.hasDescription': 'Hat Beschreibung',
|
||||
'todo.addItem': 'Neue Aufgabe hinzufügen...',
|
||||
'todo.newCategory': 'Kategoriename',
|
||||
'todo.addCategory': 'Kategorie hinzufügen',
|
||||
'todo.newItem': 'Neue Aufgabe',
|
||||
'todo.empty': 'Noch keine Aufgaben. Erstelle eine Aufgabe um loszulegen!',
|
||||
'todo.filter.my': 'Meine Aufgaben',
|
||||
'todo.filter.overdue': 'Überfällig',
|
||||
'todo.sidebar.tasks': 'Aufgaben',
|
||||
'todo.sidebar.categories': 'Kategorien',
|
||||
'todo.detail.title': 'Aufgabe',
|
||||
'todo.detail.description': 'Beschreibung',
|
||||
'todo.detail.category': 'Kategorie',
|
||||
'todo.detail.dueDate': 'Fällig am',
|
||||
'todo.detail.assignedTo': 'Zuständig',
|
||||
'todo.detail.delete': 'Löschen',
|
||||
'todo.detail.save': 'Speichern',
|
||||
'todo.sortByPrio': 'Priorität',
|
||||
'todo.detail.priority': 'Priorität',
|
||||
'todo.detail.noPriority': 'Keine',
|
||||
'todo.detail.create': 'Aufgabe erstellen',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'Neue Version verfügbar',
|
||||
'settings.notificationPreferences.noChannels': 'Keine Benachrichtigungskanäle konfiguriert. Bitte einen Administrator, E-Mail- oder Webhook-Benachrichtigungen einzurichten.',
|
||||
'settings.webhookUrl.label': 'Webhook URL',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Gib deine Discord-, Slack- oder benutzerdefinierte Webhook-URL ein, um Benachrichtigungen zu erhalten.',
|
||||
'settings.webhookUrl.save': 'Speichern',
|
||||
'settings.webhookUrl.saved': 'Webhook-URL gespeichert',
|
||||
'settings.webhookUrl.test': 'Testen',
|
||||
'settings.webhookUrl.testSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||
'settings.webhookUrl.testFailed': 'Test-Webhook fehlgeschlagen',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'In-App-Benachrichtigungen sind immer aktiv und können nicht global deaktiviert werden.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin-Webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Dieser Webhook wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Er ist unabhängig von den Benutzer-Webhooks und sendet automatisch, wenn eine URL konfiguriert ist.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'Admin-Webhook-URL gespeichert',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist',
|
||||
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
|
||||
'admin.tabs.notifications': 'Benachrichtigungen',
|
||||
'notifications.versionAvailable.title': 'Update verfügbar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
|
||||
'notifications.versionAvailable.button': 'Details anzeigen',
|
||||
'notif.test.title': '[Test] Benachrichtigung',
|
||||
'notif.test.simple.text': 'Dies ist eine einfache Testbenachrichtigung.',
|
||||
'notif.test.boolean.text': 'Akzeptierst du diese Testbenachrichtigung?',
|
||||
'notif.test.navigate.text': 'Klicke unten, um zum Dashboard zu navigieren.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Reiseeinladung',
|
||||
'notif.trip_invite.text': '{actor} hat dich zu {trip} eingeladen',
|
||||
'notif.booking_change.title': 'Buchung aktualisiert',
|
||||
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
|
||||
'notif.trip_reminder.title': 'Reiseerinnerung',
|
||||
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
|
||||
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
|
||||
'notif.photos_shared.title': 'Fotos geteilt',
|
||||
'notif.photos_shared.text': '{actor} hat {count} Foto(s) in {trip} geteilt',
|
||||
'notif.collab_message.title': 'Neue Nachricht',
|
||||
'notif.collab_message.text': '{actor} hat eine Nachricht in {trip} gesendet',
|
||||
'notif.packing_tagged.title': 'Packlistenzuweisung',
|
||||
'notif.packing_tagged.text': '{actor} hat dich zu {category} in {trip} zugewiesen',
|
||||
'notif.version_available.title': 'Neue Version verfügbar',
|
||||
'notif.version_available.text': 'TREK {version} ist jetzt verfügbar',
|
||||
'notif.action.view_trip': 'Reise ansehen',
|
||||
'notif.action.view_collab': 'Nachrichten ansehen',
|
||||
'notif.action.view_packing': 'Packliste ansehen',
|
||||
'notif.action.view_photos': 'Fotos ansehen',
|
||||
'notif.action.view_vacay': 'Vacay ansehen',
|
||||
'notif.action.view_admin': 'Zum Admin',
|
||||
'notif.action.view': 'Ansehen',
|
||||
'notif.action.accept': 'Annehmen',
|
||||
'notif.action.decline': 'Ablehnen',
|
||||
'notif.generic.title': 'Benachrichtigung',
|
||||
'notif.generic.text': 'Du hast eine neue Benachrichtigung',
|
||||
'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis',
|
||||
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user