Compare commits
357 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6825a4a0c1 | |||
| 8a4a8b58be | |||
| be975f38a6 | |||
| fa37d5b3f7 | |||
| 0ddd0c14b2 | |||
| d8367ec878 | |||
| 79057327fa | |||
| 0943184b1e | |||
| a4752ae692 | |||
| e6068d44b0 | |||
| 5d3a740791 | |||
| 2c1c77f367 | |||
| 80d013dd19 | |||
| 929105f0e4 | |||
| 93c0d6fe78 | |||
| 88a40c3294 | |||
| c056401000 | |||
| eae799c7d6 | |||
| 20ce7460c1 | |||
| d765a80ea3 | |||
| 1dc189b466 | |||
| e624ee337f | |||
| 6ba5df0215 | |||
| 897e1bff26 | |||
| ba14636c1d | |||
| 6c72295424 | |||
| f6faaa23b0 | |||
| 98813a9b40 | |||
| e0105115f4 | |||
| 217458da81 | |||
| 8dd22ab8a3 | |||
| cfdbf9235f | |||
| 059158d087 | |||
| 77393ff40b | |||
| 64d4a20403 | |||
| 6b94c0632c | |||
| cb124ba3ec | |||
| ba01b4acac | |||
| ce72f45d9a | |||
| bf2eea18c3 | |||
| 501bab0f69 | |||
| 5dd80d5cb8 | |||
| 8f6de3cd23 | |||
| 816696d0fe | |||
| bb54fda6dc | |||
| 36f2292f2d | |||
| 905c7d460b | |||
| d48714d17a | |||
| a0db42fbfe | |||
| f4d0ccb454 | |||
| a40983e65e | |||
| f32c103fe1 | |||
| 0b77fe5292 | |||
| 9afb51fcc0 | |||
| 4e10028669 | |||
| d4e16ebe49 | |||
| 4ff03a1f2c | |||
| 40f7c00adb | |||
| b43d8d119f | |||
| 74e3f85866 | |||
| bbf3f0cae8 | |||
| c0e9a771d6 | |||
| c49272efc1 | |||
| 979322025d | |||
| f0131632a7 | |||
| ffe91604b5 | |||
| e7fa8f5da9 | |||
| 3256f5156d | |||
| d45073a0bd | |||
| a4d6348a79 | |||
| c944a7d101 | |||
| 45e0c7e546 | |||
| 32b63adc68 | |||
| b1cca15f6f | |||
| dfeb7b3db7 | |||
| 50424fc574 | |||
| 12a910876e | |||
| d73a5e223c | |||
| fd9567e3fe | |||
| ae04071466 | |||
| 2ab3f59722 | |||
| 7257fac859 | |||
| 1a4c04e239 | |||
| 39a495714f | |||
| fabf5a7e26 | |||
| e71bd6768e | |||
| 71403e6303 | |||
| 43fc4db00e | |||
| e9ee2d4b0d | |||
| 228cb05932 | |||
| 505bf04a1f | |||
| 41bfcf2f76 | |||
| e308204808 | |||
| 411d5408c1 | |||
| 45684d9e44 | |||
| 0ebcff9504 | |||
| edafe01387 | |||
| 16277a3811 | |||
| ef5b381f8e | |||
| ef9880a2a5 | |||
| 95cb81b0e5 | |||
| 7d0ae631b8 | |||
| 5c04074d54 | |||
| e89ba2ecfc | |||
| 4ebf9c5f11 | |||
| add0b17e04 | |||
| 60906cf1d1 | |||
| 9292acb979 | |||
| be57b7130f | |||
| b88a8fcbb5 | |||
| 040840917c | |||
| 44e5f07f59 | |||
| c9e61859ce | |||
| 862f59b77a | |||
| 871bfd7dfd | |||
| 4d596f2ff9 | |||
| 8c85ea3644 | |||
| 19350fbc3e | |||
| 358afd2428 | |||
| 7a314a92b1 | |||
| e03505dca2 | |||
| ce8d498f2d | |||
| b109c1340a | |||
| e10f6bf9af | |||
| 6f5550dc50 | |||
| dfdd473eca | |||
| b515880adb | |||
| 78695b4e03 | |||
| 0ee53e7b38 | |||
| 1b28bd96d4 | |||
| bba50f038b | |||
| 701a8ab03a | |||
| ccb5f9df1f | |||
| c9341eda3f | |||
| fb2e8d8209 | |||
| 27fb9246e6 | |||
| 9a2c7c5db6 | |||
| d1ad5da919 | |||
| 1fbc19ad4f | |||
| 23edfe3dfc | |||
| 1ff8546484 | |||
| 6d18d5ed2d | |||
| 6d5067247c | |||
| 5e05bcd0db | |||
| 5f71b85c06 | |||
| d74133745a | |||
| eee2bbe47a | |||
| c1bce755ca | |||
| 015be3d53a | |||
| 7d3b37a2a3 | |||
| ff1c1ed56a | |||
| d5674e9a11 | |||
| 7eabe65bcf | |||
| 3444e3f446 | |||
| 9e3ac1e490 | |||
| c38e70e244 | |||
| ce7215341f | |||
| 4733955531 | |||
| 36267de117 | |||
| cd13399da5 | |||
| 36cd2feca5 | |||
| fbe3b5b17e | |||
| 10107ecf31 | |||
| 94d698e39f | |||
| 6c88a01123 | |||
| 75af89de30 | |||
| ed8518aca4 | |||
| 7522f396e7 | |||
| 9b2f083e4b | |||
| 9a949d7391 | |||
| 13904fb702 | |||
| f7160e6dec | |||
| 1983691950 | |||
| 6866644d0c | |||
| b120aabaa3 | |||
| 1d442c1d7a | |||
| 9a0294360c | |||
| 9de0c5b051 | |||
| 9e9b86f1b4 | |||
| 8ff5ec486f | |||
| 5576339bcc | |||
| e668e80f1c | |||
| 3aaa6e916b | |||
| ad329eddb9 | |||
| 990e804bd3 | |||
| 299e26bebe | |||
| 96b6d7d81f | |||
| 27d5c3400c | |||
| bb9c0c9b68 | |||
| 483190e7c1 | |||
| c89ff8b551 | |||
| 63232e56a3 | |||
| 643504d89b | |||
| 2288f9d2fc | |||
| 804c2586a9 | |||
| fedd559fd6 | |||
| 5f07bdaaf1 | |||
| fb643a1ade | |||
| 069fd99341 | |||
| 3dc760484a | |||
| 13580ea5fb | |||
| aa5dd1abc6 | |||
| de444bf770 | |||
| 821f71ac28 | |||
| faebc62917 | |||
| 41e572445c | |||
| 66f5ea50c5 | |||
| ce4b8088ec | |||
| b1138eb9db | |||
| 8412f303dd | |||
| 7272e0bbfd | |||
| c7eaf3aa79 | |||
| deef5e6b81 | |||
| 6d72006b28 | |||
| 26c1676cdd | |||
| 4ddfa92c14 | |||
| 19c9e17884 | |||
| 14ef2d4a4a | |||
| de859318fa | |||
| bcbb516448 | |||
| 71870e4567 | |||
| 9819473157 | |||
| eb7984f40d | |||
| 9caa0acc24 | |||
| 8ddfa8fde0 | |||
| 41d4b2a8be | |||
| 10ebf46a98 | |||
| 70809d6c27 | |||
| a314ba2b80 | |||
| d8f03f6bea | |||
| 533d6f84d8 | |||
| 095cb1b9d1 | |||
| 0a0205fcf9 | |||
| 9aed5ff2ed | |||
| d189d6d776 | |||
| 262905e357 | |||
| 4a4643f33f | |||
| a6a7edf0b2 | |||
| 949d0967d2 | |||
| cd634093af | |||
| 7201380504 | |||
| ba87a7f876 | |||
| 9f1b0554d6 | |||
| 1166a09835 | |||
| 6f2d7c8f5e | |||
| e6c4c22a1d | |||
| 9a044ada28 | |||
| da5e77f78d | |||
| cc8be328f9 | |||
| f1c4155d81 | |||
| d4899a8dee | |||
| a973a1b4f8 | |||
| 73b0534053 | |||
| 931c5bd990 | |||
| ee54308819 | |||
| 66b00c24e2 | |||
| f6d08582ec | |||
| 8d9a511edf | |||
| 3059d53d11 | |||
| 3074724f2f | |||
| 21ed7ea4a2 | |||
| 267271d97a | |||
| 874c1292c7 | |||
| a9948499e4 | |||
| 3dd15499e6 | |||
| 393e99201a | |||
| 153b7f64b7 | |||
| 7b2d45665c | |||
| 37873dd938 | |||
| 90301e62ce | |||
| 377422a9d5 | |||
| d90a059dfa | |||
| 1e20f024d5 | |||
| 9a81baa809 | |||
| 11b85a2d70 | |||
| d04629605e | |||
| 187989cc1d | |||
| 6444b2b4ce | |||
| 42ebc7c298 | |||
| 8bca921b30 | |||
| 12f8b6eb55 | |||
| 202cfb6a63 | |||
| b6f9664ec2 | |||
| 9f8075171d | |||
| 02b907e764 | |||
| e05e021f41 | |||
| 615c6bae58 | |||
| 62fbc26811 | |||
| 2171203a4c | |||
| b28b483b90 | |||
| 020cafade1 | |||
| e4b2262d4d | |||
| d2efd960b5 | |||
| c51a27371b | |||
| 252d2d22a8 | |||
| 80c2486570 | |||
| 7dcd89fb71 | |||
| 8458481950 | |||
| 808b7f7a72 | |||
| f4ee7b868d | |||
| e99960c3b6 | |||
| c39d242cfb | |||
| 2f8a189319 | |||
| 44138af11a | |||
| bc6c59f358 | |||
| 54804d0e5f | |||
| 631e47944b | |||
| 3abcc0ec76 | |||
| 530f233b7d | |||
| fbb3bb862c | |||
| 3c3b7b9136 | |||
| 99514ddce1 | |||
| b0ffb63d67 | |||
| d909aac751 | |||
| e91b79ebfc | |||
| 2d7babcba3 | |||
| e56ea068ef | |||
| a091051387 | |||
| df3e62af5c | |||
| 399e4acf03 | |||
| e0fd9830d9 | |||
| 7a445583d7 | |||
| 1d9d628e2d | |||
| 005c08dcea | |||
| e25fec4e4a | |||
| 85e69b8a3d | |||
| 1d57eacfa4 | |||
| ecf7433980 | |||
| 433d780f74 | |||
| 27f8856e9b | |||
| f2c90ee0f4 | |||
| 83d256ebac | |||
| 3c4f5f7193 | |||
| 31124a604a | |||
| 0d9dbb6286 | |||
| 66ae577b7b | |||
| 706548c45d | |||
| aa32df5ee1 | |||
| 1f9ae8e4b5 | |||
| d69585a820 | |||
| 723f8a1c3d | |||
| 678fe2d12c | |||
| e97ecd558f | |||
| 3d33191925 | |||
| 48e1b732d8 | |||
| d50c84b755 | |||
| fcbfeb6793 | |||
| 77f2c616de | |||
| 9f8d3f8d99 | |||
| 3f26a68f64 | |||
| a3b6a89471 | |||
| ee54d89144 | |||
| e78c2a97bd | |||
| 5940b7f24e | |||
| 1c3a1ba8da | |||
| b6d927a3d6 | |||
| c5e41f2228 |
@@ -5,6 +5,28 @@ client/dist
|
|||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.git
|
.git
|
||||||
.env
|
.github
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
*.log
|
*.log
|
||||||
*.md
|
*.md
|
||||||
|
!client/**/*.md
|
||||||
|
chart/
|
||||||
|
docs/
|
||||||
|
docker-compose.yml
|
||||||
|
unraid-template.xml
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
**/coverage
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
sonar-project.properties
|
||||||
|
server/tests/
|
||||||
|
server/vitest.config.ts
|
||||||
|
server/reset-admin.js
|
||||||
|
|||||||
@@ -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,13 +3,87 @@ name: Build & Push Docker Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
version-bump:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.bump.outputs.VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
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:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
- name: Prepare platform tag-safe name
|
||||||
|
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -18,8 +92,62 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
platforms: ${{ matrix.platform }}
|
||||||
tags: mauriceboe/nomad:latest
|
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
no-cache: true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [version-bump, build]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Download build digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create and push multi-arch manifest
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.version-bump.outputs.version }}
|
||||||
|
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||||
|
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/trek:latest
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
paths:
|
||||||
|
- 'server/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
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
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
|
||||||
# User data
|
# User data
|
||||||
server/data/
|
server/data/
|
||||||
@@ -28,6 +31,7 @@ Thumbs.db
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -52,3 +56,5 @@ coverage
|
|||||||
.cache
|
.cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
|
.scannerwork
|
||||||
@@ -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.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Stage 1: React Client bauen
|
# Stage 1: Build React client
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:22-alpine AS client-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
COPY client/package*.json ./
|
COPY client/package*.json ./
|
||||||
@@ -6,34 +6,32 @@ RUN npm ci
|
|||||||
COPY client/ ./
|
COPY client/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Produktions-Server
|
# Stage 2: Production server
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN apk add --no-cache python3 make g++ && \
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --production && \
|
||||||
apk del python3 make g++
|
apk del python3 make g++
|
||||||
|
|
||||||
# Server-Code kopieren
|
|
||||||
COPY server/ ./
|
COPY server/ ./
|
||||||
|
|
||||||
# Gebauten Client kopieren
|
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
COPY --from=client-builder /app/client/dist ./public
|
||||||
|
|
||||||
# Fonts für PDF-Export kopieren
|
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||||
|
|
||||||
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
|
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
chown -R node:node /app
|
||||||
|
|
||||||
# Umgebung setzen
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "--import", "tsx", "src/index.ts"]
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.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>
|
</picture>
|
||||||
<br />
|
<br />
|
||||||
<em>Navigation Organizer for Maps, Activities & Destinations</em>
|
<em>Your Trips. Your Plan.</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
<a href="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://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/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></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/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" alt="Last Commit" /></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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
||||||
<br />
|
<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>
|
</p>
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>More Screenshots</summary>
|
<summary>More Screenshots</summary>
|
||||||
@@ -44,13 +45,16 @@
|
|||||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
- **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
|
- **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
|
- **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
|
### 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
|
- **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)
|
- **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
|
### Mobile & PWA
|
||||||
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
||||||
@@ -61,19 +65,22 @@
|
|||||||
### Collaboration
|
### Collaboration
|
||||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
- **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
|
- **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
|
- **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
|
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||||
|
|
||||||
### Addons (modular, admin-toggleable)
|
### 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
|
- **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
|
- **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
|
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
||||||
|
|
||||||
### Customization & Admin
|
### 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
|
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||||
- **Multilingual** — English and German (i18n)
|
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
|
||||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
- **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
|
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||||
|
|
||||||
@@ -84,7 +91,7 @@
|
|||||||
- **PWA**: vite-plugin-pwa + Workbox
|
- **PWA**: vite-plugin-pwa + Workbox
|
||||||
- **Real-Time**: WebSocket (`ws`)
|
- **Real-Time**: WebSocket (`ws`)
|
||||||
- **State**: Zustand
|
- **State**: Zustand
|
||||||
- **Auth**: JWT + OIDC
|
- **Auth**: JWT + OIDC + TOTP (MFA)
|
||||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||||
- **Weather**: Open-Meteo API (free, no key required)
|
- **Weather**: Open-Meteo API (free, no key required)
|
||||||
- **Icons**: lucide-react
|
- **Icons**: lucide-react
|
||||||
@@ -92,19 +99,21 @@
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/nomad
|
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||||
|
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||||
|
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs on port `3000`. The first user to register becomes the admin.
|
The app runs on port `3000`. The first user to register becomes the admin.
|
||||||
|
|
||||||
### Install as App (PWA)
|
### 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"
|
2. **iOS**: Share button → "Add to Home Screen"
|
||||||
3. **Android**: Menu → "Install app" or "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>
|
<details>
|
||||||
<summary>Docker Compose (recommended for production)</summary>
|
<summary>Docker Compose (recommended for production)</summary>
|
||||||
@@ -112,17 +121,56 @@ NOMAD works as a Progressive Web App — no App Store needed:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/nomad:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: nomad
|
container_name: trek
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- CHOWN
|
||||||
|
- SETUID
|
||||||
|
- SETGID
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:noexec,nosuid,size=64m
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # 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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```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:
|
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull mauriceboe/nomad
|
docker pull mauriceboe/trek
|
||||||
docker rm -f nomad
|
docker rm -f trek
|
||||||
docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Tip:** Not sure which paths you used? Run `docker inspect nomad --format '{{json .Mounts}}'` before removing the container.
|
> **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.
|
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)
|
### 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>
|
<details>
|
||||||
<summary>Nginx</summary>
|
<summary>Nginx</summary>
|
||||||
@@ -163,13 +223,13 @@ For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy,
|
|||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name nomad.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name nomad.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
|
|
||||||
ssl_certificate /path/to/fullchain.pem;
|
ssl_certificate /path/to/fullchain.pem;
|
||||||
ssl_certificate_key /path/to/privkey.pem;
|
ssl_certificate_key /path/to/privkey.pem;
|
||||||
@@ -204,13 +264,46 @@ server {
|
|||||||
Caddy handles WebSocket upgrades automatically:
|
Caddy handles WebSocket upgrades automatically:
|
||||||
|
|
||||||
```
|
```
|
||||||
nomad.yourdomain.com {
|
trek.yourdomain.com {
|
||||||
reverse_proxy localhost:3000
|
reverse_proxy localhost:3000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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
|
## 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.
|
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/)
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
2. Create a project and enable the **Places API (New)**
|
2. Create a project and enable the **Places API (New)**
|
||||||
3. Create an API key under Credentials
|
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
|
## Building from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mauriceboe/NOMAD.git
|
git clone https://github.com/mauriceboe/TREK.git
|
||||||
cd NOMAD
|
cd TREK
|
||||||
docker build -t nomad .
|
docker build -t trek .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data & Backups
|
## Data & Backups
|
||||||
|
|
||||||
- **Database**: SQLite, stored in `./data/travel.db`
|
- **Database**: SQLite, stored in `./data/travel.db`
|
||||||
- **Uploads**: Stored in `./uploads/`
|
- **Uploads**: Stored in `./uploads/`
|
||||||
|
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
||||||
- **Backups**: Create and restore via Admin Panel
|
- **Backups**: Create and restore via Admin Panel
|
||||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`).
|
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||||
|
|
||||||
Third-party dependencies are monitored via GitHub Dependabot.
|
Third-party dependencies are monitored via GitHub Dependabot.
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>NOMAD</title>
|
<title>TREK</title>
|
||||||
|
|
||||||
<!-- PWA / iOS -->
|
<!-- PWA / iOS -->
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="NOMAD" />
|
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Leaflet -->
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin="" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-client",
|
"name": "trek-client",
|
||||||
"version": "2.6.1",
|
"version": "2.8.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -19,8 +19,10 @@
|
|||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.22.2",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -3,7 +3,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
|||||||
import { useAuthStore } from './store/authStore'
|
import { useAuthStore } from './store/authStore'
|
||||||
import { useSettingsStore } from './store/settingsStore'
|
import { useSettingsStore } from './store/settingsStore'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import RegisterPage from './pages/RegisterPage'
|
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import TripPlannerPage from './pages/TripPlannerPage'
|
import TripPlannerPage from './pages/TripPlannerPage'
|
||||||
import FilesPage from './pages/FilesPage'
|
import FilesPage from './pages/FilesPage'
|
||||||
@@ -11,10 +10,13 @@ import AdminPage from './pages/AdminPage'
|
|||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import VacayPage from './pages/VacayPage'
|
import VacayPage from './pages/VacayPage'
|
||||||
import AtlasPage from './pages/AtlasPage'
|
import AtlasPage from './pages/AtlasPage'
|
||||||
|
import SharedTripPage from './pages/SharedTripPage'
|
||||||
|
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||||
import { ToastContainer } from './components/shared/Toast'
|
import { ToastContainer } from './components/shared/Toast'
|
||||||
import { TranslationProvider, useTranslation } from './i18n'
|
import { TranslationProvider, useTranslation } from './i18n'
|
||||||
import DemoBanner from './components/Layout/DemoBanner'
|
|
||||||
import { authApi } from './api/client'
|
import { authApi } from './api/client'
|
||||||
|
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||||
|
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -22,8 +24,12 @@ interface ProtectedRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProtectedRoute({ children, adminRequired = false }: 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 { t } = useTranslation()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +46,15 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
|||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appRequireMfa &&
|
||||||
|
user &&
|
||||||
|
!user.mfa_enabled &&
|
||||||
|
location.pathname !== '/settings'
|
||||||
|
) {
|
||||||
|
return <Navigate to="/settings?mfa=required" replace />
|
||||||
|
}
|
||||||
|
|
||||||
if (adminRequired && user && user.role !== 'admin') {
|
if (adminRequired && user && user.role !== 'admin') {
|
||||||
return <Navigate to="/dashboard" replace />
|
return <Navigate to="/dashboard" replace />
|
||||||
}
|
}
|
||||||
@@ -62,28 +77,66 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
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()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (!location.pathname.startsWith('/shared/')) {
|
||||||
loadUser()
|
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?.demo_mode) setDemoMode(true)
|
||||||
|
if (config?.dev_mode) setDevMode(true)
|
||||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
|
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||||
|
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||||
|
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||||
|
|
||||||
|
if (config?.version) {
|
||||||
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
|
if (storedVersion && storedVersion !== config.version) {
|
||||||
|
try {
|
||||||
|
if ('caches' in window) {
|
||||||
|
const names = await caches.keys()
|
||||||
|
await Promise.all(names.map(n => caches.delete(n)))
|
||||||
|
}
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(regs.map(r => r.unregister()))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
localStorage.setItem('trek_app_version', config.version)
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem('trek_app_version', config.version)
|
||||||
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { settings } = useSettingsStore()
|
const { settings } = useSettingsStore()
|
||||||
|
|
||||||
|
useInAppNotificationListener()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}
|
}
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
const location = useLocation()
|
||||||
|
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Shared page always forces light mode
|
||||||
|
if (isSharedPage) {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) meta.setAttribute('content', '#ffffff')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const mode = settings.dark_mode
|
const mode = settings.dark_mode
|
||||||
const applyDark = (isDark: boolean) => {
|
const applyDark = (isDark: boolean) => {
|
||||||
document.documentElement.classList.toggle('dark', isDark)
|
document.documentElement.classList.toggle('dark', isDark)
|
||||||
@@ -99,7 +152,7 @@ export default function App() {
|
|||||||
return () => mq.removeEventListener('change', handler)
|
return () => mq.removeEventListener('change', handler)
|
||||||
}
|
}
|
||||||
applyDark(mode === true || mode === 'dark')
|
applyDark(mode === true || mode === 'dark')
|
||||||
}, [settings.dark_mode])
|
}, [settings.dark_mode, isSharedPage])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
@@ -107,7 +160,8 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RootRedirect />} />
|
<Route path="/" element={<RootRedirect />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||||
|
<Route path="/register" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
@@ -164,6 +218,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/notifications"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<InAppNotificationsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,18 +3,15 @@ import { getSocketId } from './websocket'
|
|||||||
|
|
||||||
const apiClient: AxiosInstance = axios.create({
|
const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request interceptor - add auth token and socket ID
|
// Request interceptor - add socket ID
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
const sid = getSocketId()
|
const sid = getSocketId()
|
||||||
if (sid) {
|
if (sid) {
|
||||||
config.headers['X-Socket-Id'] = sid
|
config.headers['X-Socket-Id'] = sid
|
||||||
@@ -28,19 +25,30 @@ apiClient.interceptors.request.use(
|
|||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
localStorage.removeItem('auth_token')
|
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
register: (data: { 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),
|
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),
|
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||||
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
||||||
@@ -56,6 +64,11 @@ export const authApi = {
|
|||||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||||
|
mcpTokens: {
|
||||||
|
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||||
|
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||||
|
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tripsApi = {
|
export const tripsApi = {
|
||||||
@@ -70,6 +83,7 @@ export const tripsApi = {
|
|||||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||||
|
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const daysApi = {
|
export const daysApi = {
|
||||||
@@ -86,6 +100,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),
|
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||||
|
importGpx: (tripId: number | string, file: File) => {
|
||||||
|
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 = {
|
export const assignmentsApi = {
|
||||||
@@ -103,9 +123,17 @@ export const assignmentsApi = {
|
|||||||
export const packingApi = {
|
export const packingApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||||
|
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||||
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||||
|
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),
|
||||||
|
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 tagsApi = {
|
export const tagsApi = {
|
||||||
@@ -134,7 +162,31 @@ export const adminApi = {
|
|||||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||||
|
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||||
|
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),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addonsApi = {
|
export const addonsApi = {
|
||||||
@@ -143,8 +195,10 @@ export const addonsApi = {
|
|||||||
|
|
||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
|
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||||
placePhoto: (placeId: string) => apiClient.get(`/maps/place-photo/${placeId}`).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 = {
|
export const budgetApi = {
|
||||||
@@ -155,15 +209,23 @@ export const budgetApi = {
|
|||||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
|
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
|
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data),
|
||||||
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
||||||
|
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
||||||
|
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||||
|
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||||
|
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||||
|
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||||
|
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||||
|
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reservationsApi = {
|
export const reservationsApi = {
|
||||||
@@ -171,6 +233,7 @@ export const reservationsApi = {
|
|||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||||
|
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
@@ -221,9 +284,8 @@ export const backupApi = {
|
|||||||
list: () => apiClient.get('/backup/list').then(r => r.data),
|
list: () => apiClient.get('/backup/list').then(r => r.data),
|
||||||
create: () => apiClient.post('/backup/create').then(r => r.data),
|
create: () => apiClient.post('/backup/create').then(r => r.data),
|
||||||
download: async (filename: string): Promise<void> => {
|
download: async (filename: string): Promise<void> => {
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
const res = await fetch(`/api/backup/download/${filename}`, {
|
const res = await fetch(`/api/backup/download/${filename}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Download failed')
|
if (!res.ok) throw new Error('Download failed')
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -245,4 +307,37 @@ export const backupApi = {
|
|||||||
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const shareApi = {
|
||||||
|
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||||
|
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
|
||||||
|
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||||
|
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||||
|
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||||
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||||
|
testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inAppNotificationsApi = {
|
||||||
|
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||||
|
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||||
|
unreadCount: () =>
|
||||||
|
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||||
|
markRead: (id: number) =>
|
||||||
|
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||||
|
markUnread: (id: number) =>
|
||||||
|
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||||
|
markAllRead: () =>
|
||||||
|
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||||
|
delete: (id: number) =>
|
||||||
|
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||||
|
deleteAll: () =>
|
||||||
|
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||||
|
respond: (id: number, response: 'positive' | 'negative') =>
|
||||||
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ let reconnectDelay = 1000
|
|||||||
const MAX_RECONNECT_DELAY = 30000
|
const MAX_RECONNECT_DELAY = 30000
|
||||||
const listeners = new Set<WebSocketListener>()
|
const listeners = new Set<WebSocketListener>()
|
||||||
const activeTrips = new Set<string>()
|
const activeTrips = new Set<string>()
|
||||||
let currentToken: string | null = null
|
let shouldReconnect = false
|
||||||
let refetchCallback: RefetchCallback | null = null
|
let refetchCallback: RefetchCallback | null = null
|
||||||
let mySocketId: string | null = null
|
let mySocketId: string | null = null
|
||||||
|
let connecting = false
|
||||||
|
|
||||||
export function getSocketId(): string | null {
|
export function getSocketId(): string | null {
|
||||||
return mySocketId
|
return mySocketId
|
||||||
@@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
|||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWsUrl(token: string): string {
|
function getWsUrl(wsToken: string): string {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
return `${protocol}://${location.host}/ws?token=${token}`
|
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWsToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/ws-token', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (resp.status === 401) {
|
||||||
|
// Session expired — stop reconnecting
|
||||||
|
shouldReconnect = false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!resp.ok) return null
|
||||||
|
const { token } = await resp.json()
|
||||||
|
return token as string
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(event: MessageEvent): void {
|
function handleMessage(event: MessageEvent): void {
|
||||||
@@ -45,19 +65,29 @@ function scheduleReconnect(): void {
|
|||||||
if (reconnectTimer) return
|
if (reconnectTimer) return
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
if (currentToken) {
|
if (shouldReconnect) {
|
||||||
connectInternal(currentToken, true)
|
connectInternal(true)
|
||||||
}
|
}
|
||||||
}, reconnectDelay)
|
}, reconnectDelay)
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectInternal(token: string, _isReconnect = false): void {
|
async function connectInternal(_isReconnect = false): Promise<void> {
|
||||||
|
if (connecting) return
|
||||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getWsUrl(token)
|
connecting = true
|
||||||
|
const wsToken = await fetchWsToken()
|
||||||
|
connecting = false
|
||||||
|
|
||||||
|
if (!wsToken) {
|
||||||
|
if (shouldReconnect) scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getWsUrl(wsToken)
|
||||||
socket = new WebSocket(url)
|
socket = new WebSocket(url)
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
@@ -82,7 +112,7 @@ function connectInternal(token: string, _isReconnect = false): void {
|
|||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
socket = null
|
socket = null
|
||||||
if (currentToken) {
|
if (shouldReconnect) {
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,18 +122,18 @@ function connectInternal(token: string, _isReconnect = false): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connect(token: string): void {
|
export function connect(): void {
|
||||||
currentToken = token
|
shouldReconnect = true
|
||||||
reconnectDelay = 1000
|
reconnectDelay = 1000
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
}
|
}
|
||||||
connectInternal(token, false)
|
connectInternal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disconnect(): void {
|
export function disconnect(): void {
|
||||||
currentToken = null
|
shouldReconnect = false
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
|
|||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Addon {
|
interface Addon {
|
||||||
@@ -27,11 +28,12 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
|||||||
return <Icon size={size} />
|
return <Icon size={size} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddonManager() {
|
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||||
const [addons, setAddons] = useState([])
|
const [addons, setAddons] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ export default function AddonManager() {
|
|||||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||||
try {
|
try {
|
||||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||||
window.dispatchEvent(new Event('addons-changed'))
|
refreshGlobalAddons()
|
||||||
toast.success(t('admin.addons.toast.updated'))
|
toast.success(t('admin.addons.toast.updated'))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Rollback
|
// Rollback
|
||||||
@@ -68,6 +70,7 @@ export default function AddonManager() {
|
|||||||
|
|
||||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||||
const globalAddons = addons.filter(a => a.type === 'global')
|
const globalAddons = addons.filter(a => a.type === 'global')
|
||||||
|
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +87,7 @@ export default function AddonManager() {
|
|||||||
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,7 +107,28 @@ export default function AddonManager() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{tripAddons.map(addon => (
|
{tripAddons.map(addon => (
|
||||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
<div key={addon.id}>
|
||||||
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-3 border-b" 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -123,6 +147,21 @@ export default function AddonManager() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Integration Addons */}
|
||||||
|
{integrationAddons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{integrationAddons.map(addon => (
|
||||||
|
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -136,8 +175,21 @@ interface AddonRowProps {
|
|||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }: AddonRowProps) {
|
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||||
const isComingSoon = false
|
const isComingSoon = false
|
||||||
|
const label = getAddonLabel(t, addon)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
@@ -148,25 +200,22 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
|
||||||
{isComingSoon && (
|
{isComingSoon && (
|
||||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||||
Coming Soon
|
Coming Soon
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
||||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{addon.description}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className="hidden sm:inline text-xs font-medium" 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')}
|
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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 { useToast } from '../shared/Toast'
|
||||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
@@ -21,19 +23,35 @@ const KEEP_OPTIONS = [
|
|||||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||||
|
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||||
|
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||||
|
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||||
|
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||||
|
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||||
|
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
|
||||||
|
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||||
|
|
||||||
export default function BackupPanel() {
|
export default function BackupPanel() {
|
||||||
const [backups, setBackups] = useState([])
|
const [backups, setBackups] = useState([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [restoringFile, setRestoringFile] = useState(null)
|
const [restoringFile, setRestoringFile] = useState(null)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||||
|
const [serverTimezone, setServerTimezone] = useState('')
|
||||||
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
|
||||||
const loadBackups = async () => {
|
const loadBackups = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -51,6 +69,7 @@ export default function BackupPanel() {
|
|||||||
try {
|
try {
|
||||||
const data = await backupApi.getAutoSettings()
|
const data = await backupApi.getAutoSettings()
|
||||||
setAutoSettings(data.settings)
|
setAutoSettings(data.settings)
|
||||||
|
if (data.timezone) setServerTimezone(data.timezone)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,10 +166,12 @@ export default function BackupPanel() {
|
|||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleString(locale, {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit',
|
hour: '2-digit', minute: '2-digit',
|
||||||
})
|
}
|
||||||
|
if (serverTimezone) opts.timeZone = serverTimezone
|
||||||
|
return new Date(dateStr).toLocaleString(locale, opts)
|
||||||
} catch { return dateStr }
|
} catch { return dateStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,9 +324,11 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||||
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -331,6 +354,68 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hour picker (for daily, weekly, monthly) */}
|
||||||
|
{autoSettings.interval !== 'hourly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={String(autoSettings.hour)}
|
||||||
|
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||||
|
size="sm"
|
||||||
|
options={HOURS.map(h => {
|
||||||
|
let label: string
|
||||||
|
if (is12h) {
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||||
|
label = `${h12}:00 ${period}`
|
||||||
|
} else {
|
||||||
|
label = `${String(h).padStart(2, '0')}:00`
|
||||||
|
}
|
||||||
|
return { value: String(h), label }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of week (for weekly) */}
|
||||||
|
{autoSettings.interval === 'weekly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS_OF_WEEK.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
autoSettings.day_of_week === opt.value
|
||||||
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(opt.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of month (for monthly) */}
|
||||||
|
{autoSettings.interval === 'monthly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={String(autoSettings.day_of_month)}
|
||||||
|
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||||
|
size="sm"
|
||||||
|
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keep duration */}
|
{/* Keep duration */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||||
|
|||||||
@@ -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">
|
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||||
<span className="sm:hidden">Add</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { adminApi, tripsApi } from '../../api/client'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } 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 send = 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 buttons = [
|
||||||
|
{
|
||||||
|
label: 'Simple → Me',
|
||||||
|
icon: Bell,
|
||||||
|
color: '#6366f1',
|
||||||
|
payload: {
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'user',
|
||||||
|
target: user?.id,
|
||||||
|
title_key: 'notifications.test.title',
|
||||||
|
title_params: { actor: user?.username || 'Admin' },
|
||||||
|
text_key: 'notifications.test.text',
|
||||||
|
text_params: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Boolean → Me',
|
||||||
|
icon: CheckCircle,
|
||||||
|
color: '#10b981',
|
||||||
|
payload: {
|
||||||
|
type: 'boolean',
|
||||||
|
scope: 'user',
|
||||||
|
target: user?.id,
|
||||||
|
title_key: 'notifications.test.booleanTitle',
|
||||||
|
title_params: { actor: user?.username || 'Admin' },
|
||||||
|
text_key: 'notifications.test.booleanText',
|
||||||
|
text_params: {},
|
||||||
|
positive_text_key: 'notifications.test.accept',
|
||||||
|
negative_text_key: 'notifications.test.decline',
|
||||||
|
positive_callback: { action: 'test_approve', payload: {} },
|
||||||
|
negative_callback: { action: 'test_deny', payload: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Navigate → Me',
|
||||||
|
icon: Navigation,
|
||||||
|
color: '#f59e0b',
|
||||||
|
payload: {
|
||||||
|
type: 'navigate',
|
||||||
|
scope: 'user',
|
||||||
|
target: user?.id,
|
||||||
|
title_key: 'notifications.test.navigateTitle',
|
||||||
|
title_params: {},
|
||||||
|
text_key: 'notifications.test.navigateText',
|
||||||
|
text_params: {},
|
||||||
|
navigate_text_key: 'notifications.test.goThere',
|
||||||
|
navigate_target: '/dashboard',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Simple → Admins',
|
||||||
|
icon: Zap,
|
||||||
|
color: '#ef4444',
|
||||||
|
payload: {
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'admin',
|
||||||
|
target: 0,
|
||||||
|
title_key: 'notifications.test.adminTitle',
|
||||||
|
title_params: {},
|
||||||
|
text_key: 'notifications.test.adminText',
|
||||||
|
text_params: { actor: user?.username || 'Admin' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-2 mb-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>
|
||||||
|
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quick-fire buttons */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{buttons.map(btn => {
|
||||||
|
const Icon = btn.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={btn.label}
|
||||||
|
onClick={() => send(btn.label, btn.payload)}
|
||||||
|
disabled={sending !== null}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||||
|
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: `${btn.color}20`, color: btn.color }}>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
|
||||||
|
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{btn.payload.type} · {btn.payload.scope}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{sending === btn.label && (
|
||||||
|
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trip-scoped notifications */}
|
||||||
|
{trips.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<select
|
||||||
|
value={selectedTripId ?? ''}
|
||||||
|
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => selectedTripId && send('Simple → Trip', {
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'trip',
|
||||||
|
target: selectedTripId,
|
||||||
|
title_key: 'notifications.test.tripTitle',
|
||||||
|
title_params: { actor: user?.username || 'Admin' },
|
||||||
|
text_key: 'notifications.test.tripText',
|
||||||
|
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||||
|
})}
|
||||||
|
disabled={sending !== null || !selectedTripId}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||||
|
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: '#8b5cf620', color: '#8b5cf6' }}>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → Trip Members</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · trip</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectedTripId && send('Navigate → Trip', {
|
||||||
|
type: 'navigate',
|
||||||
|
scope: 'trip',
|
||||||
|
target: selectedTripId,
|
||||||
|
title_key: 'notifications.test.tripTitle',
|
||||||
|
title_params: { actor: user?.username || 'Admin' },
|
||||||
|
text_key: 'notifications.test.tripText',
|
||||||
|
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||||
|
navigate_text_key: 'notifications.test.goThere',
|
||||||
|
navigate_target: `/trips/${selectedTripId}`,
|
||||||
|
})}
|
||||||
|
disabled={sending !== null || !selectedTripId}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||||
|
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: '#f59e0b20', color: '#f59e0b' }}>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → Trip Members</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User-scoped notifications */}
|
||||||
|
{users.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<select
|
||||||
|
value={selectedUserId ?? ''}
|
||||||
|
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||||
|
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'user',
|
||||||
|
target: selectedUserId,
|
||||||
|
title_key: 'notifications.test.title',
|
||||||
|
title_params: { actor: user?.username || 'Admin' },
|
||||||
|
text_key: 'notifications.test.text',
|
||||||
|
text_params: {},
|
||||||
|
})}
|
||||||
|
disabled={sending !== null || !selectedUserId}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||||
|
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: '#06b6d420', color: '#06b6d4' }}>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → User</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · user</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectedUserId && send(`Boolean → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||||
|
type: 'boolean',
|
||||||
|
scope: 'user',
|
||||||
|
target: selectedUserId,
|
||||||
|
title_key: 'notifications.test.booleanTitle',
|
||||||
|
title_params: { actor: user?.username || 'Admin' },
|
||||||
|
text_key: 'notifications.test.booleanText',
|
||||||
|
text_params: {},
|
||||||
|
positive_text_key: 'notifications.test.accept',
|
||||||
|
negative_text_key: 'notifications.test.decline',
|
||||||
|
positive_callback: { action: 'test_approve', payload: {} },
|
||||||
|
negative_callback: { action: 'test_deny', payload: {} },
|
||||||
|
})}
|
||||||
|
disabled={sending !== null || !selectedUserId}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||||
|
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: '#10b98120', color: '#10b981' }}>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Boolean → User</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>boolean · user</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => selectedUserId && send(`Navigate → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||||
|
type: 'navigate',
|
||||||
|
scope: 'user',
|
||||||
|
target: selectedUserId,
|
||||||
|
title_key: 'notifications.test.navigateTitle',
|
||||||
|
title_params: {},
|
||||||
|
text_key: 'notifications.test.navigateText',
|
||||||
|
text_params: {},
|
||||||
|
navigate_text_key: 'notifications.test.goThere',
|
||||||
|
navigate_target: '/dashboard',
|
||||||
|
})}
|
||||||
|
disabled={sending !== null || !selectedUserId}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||||
|
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: '#f59e0b20', color: '#f59e0b' }}>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → User</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · user</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
|
||||||
const REPO = 'mauriceboe/NOMAD'
|
const REPO = 'mauriceboe/NOMAD'
|
||||||
const PER_PAGE = 10
|
const PER_PAGE = 10
|
||||||
@@ -17,9 +18,8 @@ export default function GitHubPanel() {
|
|||||||
|
|
||||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`)
|
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||||
if (!res.ok) throw new Error(`GitHub API: ${res.status}`)
|
const data = Array.isArray(res.data) ? res.data : []
|
||||||
const data = await res.json()
|
|
||||||
setReleases(prev => append ? [...prev, ...data] : data)
|
setReleases(prev => append ? [...prev, ...data] : data)
|
||||||
setHasMore(data.length === PER_PAGE)
|
setHasMore(data.length === PER_PAGE)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -46,7 +46,7 @@ export default function GitHubPanel() {
|
|||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||||
@@ -72,11 +72,15 @@ export default function GitHubPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
const inlineFormat = (text) => {
|
const inlineFormat = (text) => {
|
||||||
return text
|
return escapeHtml(text)
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||||
|
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||||
|
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -112,30 +116,81 @@ export default function GitHubPanel() {
|
|||||||
return elements
|
return elements
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="p-8 flex items-center justify-center">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="p-6 text-center">
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Header card */}
|
{/* Support cards */}
|
||||||
|
<div className="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>
|
||||||
|
|
||||||
|
{/* Loading / Error / Releases */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="p-8 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -258,6 +313,7 @@ export default function GitHubPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +2,14 @@ import ReactDOM from 'react-dom'
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
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 CustomSelect from '../shared/CustomSelect'
|
||||||
import { budgetApi } from '../../api/client'
|
import { budgetApi } from '../../api/client'
|
||||||
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import type { BudgetItem, BudgetMember } from '../../types'
|
import type { BudgetItem, BudgetMember } from '../../types'
|
||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
|
||||||
interface TripMember {
|
interface TripMember {
|
||||||
id: number
|
id: number
|
||||||
@@ -28,13 +31,29 @@ interface PerPersonSummaryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
const CURRENCIES = [
|
||||||
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
|
'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 PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||||
|
|
||||||
const fmtNum = (v, locale, cur) => {
|
const fmtNum = (v, locale, cur) => {
|
||||||
if (v == null || isNaN(v)) return '-'
|
if (v == null || isNaN(v)) return '-'
|
||||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur)
|
const d = currencyDecimals(cur)
|
||||||
|
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||||
}
|
}
|
||||||
|
|
||||||
const calcPP = (p, n) => (n > 0 ? p / n : null)
|
const calcPP = (p, n) => (n > 0 ? p / n : null)
|
||||||
@@ -42,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)
|
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
||||||
|
|
||||||
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
// ── 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 [editing, setEditing] = useState(false)
|
||||||
const [editValue, setEditValue] = useState(value ?? '')
|
const [editValue, setEditValue] = useState(value ?? '')
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
@@ -69,12 +88,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
|||||||
: (value || '')
|
: (value || '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
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',
|
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||||
{display || placeholder || '-'}
|
{display || placeholder || '-'}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -82,7 +101,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
|||||||
|
|
||||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||||
interface AddItemRowProps {
|
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
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +111,13 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
|||||||
const [persons, setPersons] = useState('')
|
const [persons, setPersons] = useState('')
|
||||||
const [days, setDays] = useState('')
|
const [days, setDays] = useState('')
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
|
const [expenseDate, setExpenseDate] = useState('')
|
||||||
const nameRef = useRef(null)
|
const nameRef = useRef(null)
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!name.trim()) return
|
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 })
|
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('')
|
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||||
setTimeout(() => nameRef.current?.focus(), 50)
|
setTimeout(() => nameRef.current?.focus(), 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,15 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
<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()}
|
<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>
|
||||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
<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()}
|
<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>
|
||||||
<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 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 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' }}>
|
<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} />
|
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||||
</td>
|
</td>
|
||||||
@@ -143,9 +168,11 @@ interface ChipWithTooltipProps {
|
|||||||
label: string
|
label: string
|
||||||
avatarUrl: string | null
|
avatarUrl: string | null
|
||||||
size?: number
|
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 [hover, setHover] = useState(false)
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
@@ -158,13 +185,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
|
|||||||
setHover(true)
|
setHover(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||||
|
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||||
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
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
|
{avatarUrl
|
||||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
@@ -175,11 +208,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
|
|||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
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)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
}}>
|
}}>
|
||||||
{label}
|
{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>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
@@ -192,10 +233,12 @@ interface BudgetMemberChipsProps {
|
|||||||
members?: BudgetMember[]
|
members?: BudgetMember[]
|
||||||
tripMembers?: TripMember[]
|
tripMembers?: TripMember[]
|
||||||
onSetMembers: (memberIds: number[]) => void
|
onSetMembers: (memberIds: number[]) => void
|
||||||
|
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||||
compact?: boolean
|
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 chipSize = compact ? 20 : 30
|
||||||
const btnSize = compact ? 18 : 28
|
const btnSize = compact ? 18 : 28
|
||||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||||
@@ -235,16 +278,21 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
{members.map(m => (
|
{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}
|
{!readOnly && (
|
||||||
style={{
|
<button ref={btnRef} onClick={openDropdown}
|
||||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
style={{
|
||||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
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>
|
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{showDropdown && ReactDOM.createPortal(
|
{showDropdown && ReactDOM.createPortal(
|
||||||
<div ref={dropRef} style={{
|
<div ref={dropRef} style={{
|
||||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||||
@@ -374,15 +422,25 @@ interface BudgetPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
|
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||||
|
const can = useCanDo()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
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 currency = trip?.currency || 'EUR'
|
||||||
|
const canEdit = can('budget_edit', trip)
|
||||||
|
|
||||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||||
const hasMultipleMembers = tripMembers.length > 1
|
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) => {
|
const setCurrency = (cur) => {
|
||||||
if (tripId) updateTrip(tripId, { currency: cur })
|
if (tripId) updateTrip(tripId, { currency: cur })
|
||||||
}
|
}
|
||||||
@@ -425,6 +483,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
setNewCategoryName('')
|
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 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)' }
|
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||||
|
|
||||||
@@ -437,16 +530,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
{canEdit && (
|
||||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||||
placeholder={t('budget.emptyPlaceholder')}
|
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
placeholder={t('budget.emptyPlaceholder')}
|
||||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||||
<Plus size={16} />
|
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||||
</button>
|
<Plus size={16} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -459,6 +554,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<Calculator size={20} color="var(--text-primary)" />
|
<Calculator size={20} color="var(--text-primary)" />
|
||||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Download size={13} /> CSV
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
@@ -473,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', 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={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||||
{editingCat?.name === cat ? (
|
{canEdit && editingCat?.name === cat ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={editingCat.value}
|
value={editingCat.value}
|
||||||
@@ -485,21 +584,25 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
{canEdit && (
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||||
<Pencil size={10} />
|
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||||
</button>
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
{canEdit && (
|
||||||
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 }}
|
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
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 }}
|
||||||
<Trash2 size={13} />
|
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||||
</button>
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -507,14 +610,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||||
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
<th style={{ ...th, minWidth: 75 }}>{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: 160 }}>{t('budget.table.persons')}</th>
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</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: 90 }}>{t('budget.table.perPerson')}</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: 80 }}>{t('budget.table.perDay')}</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 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>
|
<th style={{ ...th, width: 36 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -529,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
<td style={td}>
|
<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={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
{/* Mobile: larger chips under name since Persons column is hidden */}
|
||||||
{hasMultipleMembers && (
|
{hasMultipleMembers && (
|
||||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||||
@@ -537,13 +641,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
members={item.members || []}
|
members={item.members || []}
|
||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||||
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
compact={false}
|
compact={false}
|
||||||
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...td, textAlign: 'center' }}>
|
<td style={{ ...td, textAlign: 'center' }}>
|
||||||
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
|
<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>
|
||||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||||
{hasMultipleMembers ? (
|
{hasMultipleMembers ? (
|
||||||
@@ -551,29 +657,42 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
members={item.members || []}
|
members={item.members || []}
|
||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
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>
|
||||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
<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>
|
||||||
<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: 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 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 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' }}>
|
<td style={{ ...td, textAlign: 'center' }}>
|
||||||
|
{canEdit && (
|
||||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
<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' }}
|
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'}>
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
|
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -582,29 +701,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={setCurrency}
|
onChange={setCurrency}
|
||||||
|
disabled={!canEdit}
|
||||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
{canEdit && (
|
||||||
<input
|
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||||
value={newCategoryName}
|
<input
|
||||||
onChange={e => setNewCategoryName(e.target.value)}
|
value={newCategoryName}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
onChange={e => setNewCategoryName(e.target.value)}
|
||||||
placeholder={t('budget.categoryName')}
|
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||||
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)' }}
|
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 }}>
|
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||||
<Plus size={16} />
|
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 }}>
|
||||||
</button>
|
<Plus size={16} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||||
@@ -619,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 style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||||
</div>
|
</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: 2, maximumFractionDigits: 2 })}
|
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {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) && (
|
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
{pieSegments.length > 0 && (
|
{pieSegments.length > 0 && (
|
||||||
@@ -639,27 +846,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
|
|
||||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
<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 => {
|
{pieSegments.map(seg => {
|
||||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||||
return (
|
return (
|
||||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
<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={{ 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>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{pieSegments.map(seg => (
|
|
||||||
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'
|
|||||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
@@ -353,6 +355,9 @@ interface CollabChatProps {
|
|||||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
|
|
||||||
const [messages, setMessages] = useState([])
|
const [messages, setMessages] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
onMouseEnter={() => setHoveredId(msg.id)}
|
onMouseEnter={() => setHoveredId(msg.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||||
onTouchEnd={e => {
|
onTouchEnd={e => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||||
if (now - lastTap < 300) {
|
if (now - lastTap < 300 && canEdit) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const touch = e.changedTouches?.[0]
|
const touch = e.changedTouches?.[0]
|
||||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||||
@@ -692,7 +697,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
transition: 'opacity .1s',
|
transition: 'opacity .1s',
|
||||||
...(own ? { left: -6 } : { right: -6 }),
|
...(own ? { left: -6 } : { right: -6 }),
|
||||||
}}>
|
}}>
|
||||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||||
@@ -703,8 +708,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
>
|
>
|
||||||
<Reply size={11} />
|
<Reply size={11} />
|
||||||
</button>
|
</button>
|
||||||
{own && (
|
{own && canEdit && (
|
||||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||||
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
{msg.reactions.map(r => {
|
{msg.reactions.map(r => {
|
||||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||||
return (
|
return (
|
||||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||||
{/* Emoji button */}
|
{/* Emoji button */}
|
||||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
{canEdit && (
|
||||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||||
<Smile size={20} />
|
}}>
|
||||||
</button>
|
<Smile size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
disabled={!canEdit}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||||
maxHeight: 100, overflowY: 'hidden',
|
maxHeight: 100, overflowY: 'hidden',
|
||||||
|
opacity: canEdit ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
placeholder={t('collab.chat.placeholder')}
|
placeholder={t('collab.chat.placeholder')}
|
||||||
value={text}
|
value={text}
|
||||||
@@ -805,15 +814,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Send */}
|
{/* Send */}
|
||||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
{canEdit && (
|
||||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
transition: 'background 0.15s',
|
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||||
}}>
|
transition: 'background 0.15s',
|
||||||
<ArrowUp size={18} strokeWidth={2.5} />
|
}}>
|
||||||
</button>
|
<ArrowUp size={18} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink } from 'lucide-react'
|
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 { collabApi } from '../../api/client'
|
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 { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
@@ -92,22 +97,33 @@ interface FilePreviewPortalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||||
|
const [authUrl, setAuthUrl] = useState('')
|
||||||
|
const rawUrl = file?.url || ''
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rawUrl) return
|
||||||
|
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||||
|
}, [rawUrl])
|
||||||
|
|
||||||
if (!file) return null
|
if (!file) return null
|
||||||
const url = file.url || `/uploads/${file.filename}`
|
|
||||||
const isImage = file.mime_type?.startsWith('image/')
|
const isImage = file.mime_type?.startsWith('image/')
|
||||||
const isPdf = file.mime_type === 'application/pdf'
|
const isPdf = file.mime_type === 'application/pdf'
|
||||||
const isTxt = file.mime_type?.startsWith('text/')
|
const isTxt = file.mime_type?.startsWith('text/')
|
||||||
|
|
||||||
|
const openInNewTab = async () => {
|
||||||
|
const u = await getAuthUrl(rawUrl, 'download')
|
||||||
|
window.open(u, '_blank', 'noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
/* Image lightbox — floating controls */
|
/* Image lightbox — floating controls */
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||||
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
<img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
|
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,19 +134,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
|
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(isPdf || isTxt) ? (
|
{(isPdf || isTxt) ? (
|
||||||
<object data={`${url}#view=FitH`} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
|
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
|
||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
|
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +156,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||||
|
const [authSrc, setAuthSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||||
|
}, [src])
|
||||||
|
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
const NOTE_COLORS = [
|
const NOTE_COLORS = [
|
||||||
{ value: '#6366f1', label: 'Indigo' },
|
{ value: '#6366f1', label: 'Indigo' },
|
||||||
{ value: '#ef4444', label: 'Red' },
|
{ value: '#ef4444', label: 'Red' },
|
||||||
@@ -214,7 +238,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
|||||||
interface NoteFormModalProps {
|
interface NoteFormModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
||||||
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
|
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||||
existingCategories: string[]
|
existingCategories: string[]
|
||||||
categoryColors: Record<string, string>
|
categoryColors: Record<string, string>
|
||||||
getCategoryColor: (category: string) => string
|
getCategoryColor: (category: string) => string
|
||||||
@@ -224,6 +248,9 @@ interface NoteFormModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
||||||
|
const can = useCanDo()
|
||||||
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
|
const canUploadFiles = can('file_upload', tripObj)
|
||||||
const isEdit = !!note
|
const isEdit = !!note
|
||||||
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
||||||
|
|
||||||
@@ -296,6 +323,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
}}
|
}}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onPaste={e => {
|
onPaste={e => {
|
||||||
|
if (!canUploadFiles) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -412,7 +440,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
resize: 'vertical',
|
resize: 'vertical',
|
||||||
minHeight: 90,
|
minHeight: 180,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -448,11 +476,11 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File attachments */}
|
{/* File attachments */}
|
||||||
<div>
|
{canUploadFiles && <div>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||||
{t('collab.notes.attachFiles')}
|
{t('collab.notes.attachFiles')}
|
||||||
</div>
|
</div>
|
||||||
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
|
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{/* Existing attachments (edit mode) */}
|
{/* Existing attachments (edit mode) */}
|
||||||
{existingAttachments.map(a => {
|
{existingAttachments.map(a => {
|
||||||
@@ -476,12 +504,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<label htmlFor="note-file-input"
|
<button type="button" onClick={() => fileRef.current?.click()}
|
||||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||||
</label>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
@@ -687,16 +715,18 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
|
|||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
note: CollabNote
|
note: CollabNote
|
||||||
currentUser: User
|
currentUser: User
|
||||||
|
canEdit: boolean
|
||||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||||
onDelete: (noteId: number) => Promise<void>
|
onDelete: (noteId: number) => Promise<void>
|
||||||
onEdit: (note: CollabNote) => void
|
onEdit: (note: CollabNote) => void
|
||||||
|
onView: (note: CollabNote) => void
|
||||||
onPreviewFile: (file: NoteFile) => void
|
onPreviewFile: (file: NoteFile) => void
|
||||||
getCategoryColor: (category: string) => string
|
getCategoryColor: (category: string) => string
|
||||||
tripId: number
|
tripId: number
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||||
@@ -749,24 +779,32 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', gap: 2,
|
display: 'flex', gap: 2,
|
||||||
}}>
|
}}>
|
||||||
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
{note.content && (
|
||||||
|
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
|
||||||
|
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)'}>
|
||||||
|
<Maximize2 size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Pencil size={10} />
|
<Pencil size={10} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={handleDelete} title={t('collab.notes.delete')}
|
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Trash2 size={10} />
|
<Trash2 size={10} />
|
||||||
</button>
|
</button>}
|
||||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||||
{/* Author avatar */}
|
{/* Author avatar */}
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||||
@@ -799,13 +837,13 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
|
|||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{note.content && (
|
{note.content && (
|
||||||
<p style={{
|
<div className="collab-note-md" style={{
|
||||||
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||||
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
|
maxHeight: '4.5em', overflow: 'hidden',
|
||||||
overflow: 'hidden', wordBreak: 'break-word', fontFamily: FONT,
|
wordBreak: 'break-word', fontFamily: FONT,
|
||||||
}}>
|
}}>
|
||||||
{note.content}
|
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Right: website + attachment thumbnails */}
|
{/* Right: website + attachment thumbnails */}
|
||||||
@@ -827,7 +865,7 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
|
|||||||
const isImage = a.mime_type?.startsWith('image/')
|
const isImage = a.mime_type?.startsWith('image/')
|
||||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||||
return isImage ? (
|
return isImage ? (
|
||||||
<img key={a.id} src={a.url} alt={a.original_name}
|
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||||
onClick={() => onPreviewFile?.(a)}
|
onClick={() => onPreviewFile?.(a)}
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||||
@@ -868,10 +906,14 @@ interface CollabNotesProps {
|
|||||||
|
|
||||||
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
const [notes, setNotes] = useState([])
|
const [notes, setNotes] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showNewModal, setShowNewModal] = useState(false)
|
const [showNewModal, setShowNewModal] = useState(false)
|
||||||
const [editingNote, setEditingNote] = useState(null)
|
const [editingNote, setEditingNote] = useState(null)
|
||||||
|
const [viewingNote, setViewingNote] = useState<CollabNote | null>(null)
|
||||||
const [previewFile, setPreviewFile] = useState(null)
|
const [previewFile, setPreviewFile] = useState(null)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [activeCategory, setActiveCategory] = useState(null)
|
const [activeCategory, setActiveCategory] = useState(null)
|
||||||
@@ -952,7 +994,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
for (const file of pendingFiles) {
|
for (const file of pendingFiles) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {}
|
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
|
||||||
}
|
}
|
||||||
// Reload note with attachments
|
// Reload note with attachments
|
||||||
const fresh = await collabApi.getNotes(tripId)
|
const fresh = await collabApi.getNotes(tripId)
|
||||||
@@ -1112,17 +1154,17 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
{t('collab.notes.title')}
|
{t('collab.notes.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Settings size={14} />
|
<Settings size={14} />
|
||||||
</button>
|
</button>}
|
||||||
<button onClick={() => setShowNewModal(true)}
|
{canEdit && <button onClick={() => setShowNewModal(true)}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
{t('collab.notes.new')}
|
{t('collab.notes.new')}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1240,9 +1282,11 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
key={note.id}
|
key={note.id}
|
||||||
note={note}
|
note={note}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
canEdit={canEdit}
|
||||||
onUpdate={handleUpdateNote}
|
onUpdate={handleUpdateNote}
|
||||||
onDelete={handleDeleteNote}
|
onDelete={handleDeleteNote}
|
||||||
onEdit={setEditingNote}
|
onEdit={setEditingNote}
|
||||||
|
onView={setViewingNote}
|
||||||
onPreviewFile={setPreviewFile}
|
onPreviewFile={setPreviewFile}
|
||||||
getCategoryColor={getCategoryColor}
|
getCategoryColor={getCategoryColor}
|
||||||
tripId={tripId}
|
tripId={tripId}
|
||||||
@@ -1254,8 +1298,68 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── New Note Modal ── */}
|
{/* ── New Note Modal ── */}
|
||||||
|
{/* View note modal */}
|
||||||
|
{viewingNote && ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
|
||||||
|
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 10000, padding: 16,
|
||||||
|
}}
|
||||||
|
onClick={() => setViewingNote(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 16,
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
|
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
|
||||||
|
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
|
||||||
|
{viewingNote.category && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
|
||||||
|
color: getCategoryColor(viewingNote.category),
|
||||||
|
background: `${getCategoryColor(viewingNote.category)}18`,
|
||||||
|
padding: '2px 8px', borderRadius: 6,
|
||||||
|
}}>{viewingNote.category}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||||
|
{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 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)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||||
|
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{showNewModal && (
|
{showNewModal && (
|
||||||
<NoteFormModal
|
<NoteFormModal
|
||||||
|
note={null}
|
||||||
|
tripId={tripId}
|
||||||
onClose={() => setShowNewModal(false)}
|
onClose={() => setShowNewModal(false)}
|
||||||
onSubmit={handleCreateNote}
|
onSubmit={handleCreateNote}
|
||||||
existingCategories={categories}
|
existingCategories={categories}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
|||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
|
|
||||||
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
|||||||
interface PollCardProps {
|
interface PollCardProps {
|
||||||
poll: Poll
|
poll: Poll
|
||||||
currentUser: User
|
currentUser: User
|
||||||
|
canEdit: boolean
|
||||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||||
onClose: (pollId: number) => Promise<void>
|
onClose: (pollId: number) => Promise<void>
|
||||||
onDelete: (pollId: number) => Promise<void>
|
onDelete: (pollId: number) => Promise<void>
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
|
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||||
const total = totalVotes(poll)
|
const total = totalVotes(poll)
|
||||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||||
const remaining = timeRemaining(poll.deadline)
|
const remaining = timeRemaining(poll.deadline)
|
||||||
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
{canEdit && (
|
||||||
{!isClosed && (
|
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
{!isClosed && (
|
||||||
|
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||||
|
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Lock size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
<Lock size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
)}
|
||||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options */}
|
{/* Options */}
|
||||||
@@ -337,6 +342,9 @@ interface CollabPollsProps {
|
|||||||
|
|
||||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
|
const canEdit = can('collab_edit', trip)
|
||||||
const [polls, setPolls] = useState([])
|
const [polls, setPolls] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
<BarChart3 size={14} color="var(--text-faint)" />
|
<BarChart3 size={14} color="var(--text-faint)" />
|
||||||
{t('collab.polls.title')}
|
{t('collab.polls.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setShowForm(true)} style={{
|
{canEdit && (
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
<button onClick={() => setShowForm(true)} style={{
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||||
}}>
|
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||||
<Plus size={12} /> {t('collab.polls.new')}
|
}}>
|
||||||
</button>
|
<Plus size={12} /> {t('collab.polls.new')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{activePolls.length > 0 && activePolls.map(poll => (
|
{activePolls.length > 0 && activePolls.map(poll => (
|
||||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||||
))}
|
))}
|
||||||
{closedPolls.length > 0 && (
|
{closedPolls.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{closedPolls.map(poll => (
|
{closedPolls.map(poll => (
|
||||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) {
|
|||||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
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 {
|
interface TripMember {
|
||||||
|
|||||||
@@ -4,17 +4,23 @@ import { useTranslation } from '../../i18n'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
|
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||||
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
|
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||||
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
|
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||||
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
|
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||||
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
|
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||||
|
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||||
|
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||||
|
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||||
|
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||||
|
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||||
|
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||||
]
|
]
|
||||||
|
|
||||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||||
|
|
||||||
export default function CurrencyWidget() {
|
export default function CurrencyWidget() {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||||
const [amount, setAmount] = useState('100')
|
const [amount, setAmount] = useState('100')
|
||||||
@@ -40,7 +46,7 @@ export default function CurrencyWidget() {
|
|||||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||||
const formatNumber = (num) => {
|
const formatNumber = (num) => {
|
||||||
if (!num || num === '—') return '—'
|
if (!num || num === '—') return '—'
|
||||||
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
}
|
}
|
||||||
const result = rawResult
|
const result = rawResult
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Clock, Plus, X } from 'lucide-react'
|
import { Clock, Plus, X } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
|
||||||
const POPULAR_ZONES = [
|
const POPULAR_ZONES = [
|
||||||
{ label: 'New York', tz: 'America/New_York' },
|
{ label: 'New York', tz: 'America/New_York' },
|
||||||
@@ -23,9 +24,9 @@ const POPULAR_ZONES = [
|
|||||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function getTime(tz) {
|
function getTime(tz, locale, is12h) {
|
||||||
try {
|
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 '—' }
|
} catch { return '—' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +42,8 @@ function getOffset(tz) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TimezoneWidget() {
|
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 [zones, setZones] = useState(() => {
|
||||||
const saved = localStorage.getItem('dashboard_timezones')
|
const saved = localStorage.getItem('dashboard_timezones')
|
||||||
return saved ? JSON.parse(saved) : [
|
return saved ? JSON.parse(saved) : [
|
||||||
@@ -51,6 +53,9 @@ export default function TimezoneWidget() {
|
|||||||
})
|
})
|
||||||
const [now, setNow] = useState(Date.now())
|
const [now, setNow] = useState(Date.now())
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [customLabel, setCustomLabel] = useState('')
|
||||||
|
const [customTz, setCustomTz] = useState('')
|
||||||
|
const [customError, setCustomError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||||
@@ -61,6 +66,20 @@ export default function TimezoneWidget() {
|
|||||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||||
}, [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) => {
|
const addZone = (zone) => {
|
||||||
if (!zones.find(z => z.tz === zone.tz)) {
|
if (!zones.find(z => z.tz === zone.tz)) {
|
||||||
setZones([...zones, zone])
|
setZones([...zones, zone])
|
||||||
@@ -70,7 +89,7 @@ export default function TimezoneWidget() {
|
|||||||
|
|
||||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||||
|
|
||||||
const localTime = new Date().toLocaleTimeString('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 rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||||
@@ -96,7 +115,7 @@ export default function TimezoneWidget() {
|
|||||||
{zones.map(z => (
|
{zones.map(z => (
|
||||||
<div key={z.tz} className="flex items-center justify-between group">
|
<div key={z.tz} className="flex items-center justify-between group">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz)}</p>
|
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||||
@@ -108,7 +127,29 @@ export default function TimezoneWidget() {
|
|||||||
|
|
||||||
{/* Add zone dropdown */}
|
{/* Add zone dropdown */}
|
||||||
{showAdd && (
|
{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 => (
|
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||||
<button key={z.tz} onClick={() => addZone(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"
|
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)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
<span className="font-medium">{z.label}</span>
|
<span className="font-medium">{z.label}</span>
|
||||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz)}</span>
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import DOM from 'react-dom'
|
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react'
|
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { Place, Reservation, TripFile } from '../../types'
|
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) {
|
function isImage(mimeType) {
|
||||||
if (!mimeType) return false
|
if (!mimeType) return false
|
||||||
return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc.
|
return mimeType.startsWith('image/')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileIcon(mimeType) {
|
function getFileIcon(mimeType) {
|
||||||
@@ -41,6 +45,10 @@ interface ImageLightboxProps {
|
|||||||
|
|
||||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [imgSrc, setImgSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||||
|
}, [file.url])
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
@@ -48,16 +56,20 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
|||||||
>
|
>
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||||
<img
|
<img
|
||||||
src={file.url}
|
src={imgSrc}
|
||||||
alt={file.original_name}
|
alt={file.original_name}
|
||||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
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' }}>
|
<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>
|
<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 }}>
|
<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')}>
|
<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: 0 }}
|
||||||
|
title={t('files.openTab')}
|
||||||
|
>
|
||||||
<ExternalLink size={16} />
|
<ExternalLink size={16} />
|
||||||
</a>
|
</button>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -68,7 +80,16 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source badge — unified style for both place and reservation
|
// 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 {
|
interface SourceBadgeProps {
|
||||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||||
label: string
|
label: string
|
||||||
@@ -89,40 +110,158 @@ function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
if (ref.current) {
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||||
|
}
|
||||||
|
setHover(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
||||||
|
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||||
|
cursor: 'default',
|
||||||
|
}}>
|
||||||
|
{avatarUrl
|
||||||
|
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: name?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{hover && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||||
|
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface FileManagerProps {
|
interface FileManagerProps {
|
||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onUpload: (fd: FormData) => Promise<void>
|
onUpload: (fd: FormData) => Promise<any>
|
||||||
onDelete: (fileId: number) => Promise<void>
|
onDelete: (fileId: number) => Promise<void>
|
||||||
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
|
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
|
||||||
places: Place[]
|
places: Place[]
|
||||||
|
days?: Day[]
|
||||||
|
assignments?: AssignmentsMap
|
||||||
reservations?: Reservation[]
|
reservations?: Reservation[]
|
||||||
tripId: number
|
tripId: number
|
||||||
allowedFileTypes: Record<string, string[]>
|
allowedFileTypes: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [filterType, setFilterType] = useState('all')
|
const [filterType, setFilterType] = useState('all')
|
||||||
const [lightboxFile, setLightboxFile] = useState(null)
|
const [lightboxFile, setLightboxFile] = useState(null)
|
||||||
|
const [showTrash, setShowTrash] = useState(false)
|
||||||
|
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
||||||
|
const [loadingTrash, setLoadingTrash] = useState(false)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore((s) => s.trip)
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
|
||||||
|
const loadTrash = useCallback(async () => {
|
||||||
|
setLoadingTrash(true)
|
||||||
|
try {
|
||||||
|
const data = await filesApi.list(tripId, true)
|
||||||
|
setTrashFiles(data.files || [])
|
||||||
|
} catch { /* */ }
|
||||||
|
setLoadingTrash(false)
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const toggleTrash = useCallback(() => {
|
||||||
|
if (!showTrash) loadTrash()
|
||||||
|
setShowTrash(v => !v)
|
||||||
|
}, [showTrash, loadTrash])
|
||||||
|
|
||||||
|
const refreshFiles = useCallback(async () => {
|
||||||
|
if (onUpdate) onUpdate(0, {} as any)
|
||||||
|
}, [onUpdate])
|
||||||
|
|
||||||
|
const handleStar = async (fileId: number) => {
|
||||||
|
try {
|
||||||
|
await filesApi.toggleStar(tripId, fileId)
|
||||||
|
refreshFiles()
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (fileId: number) => {
|
||||||
|
try {
|
||||||
|
await filesApi.restore(tripId, fileId)
|
||||||
|
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
||||||
|
refreshFiles()
|
||||||
|
toast.success(t('files.toast.restored'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.restoreError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePermanentDelete = async (fileId: number) => {
|
||||||
|
if (!confirm(t('files.confirm.permanentDelete'))) return
|
||||||
|
try {
|
||||||
|
await filesApi.permanentDelete(tripId, fileId)
|
||||||
|
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
||||||
|
toast.success(t('files.toast.deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmptyTrash = async () => {
|
||||||
|
if (!confirm(t('files.confirm.emptyTrash'))) return
|
||||||
|
try {
|
||||||
|
await filesApi.emptyTrash(tripId)
|
||||||
|
setTrashFiles([])
|
||||||
|
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lastUploadedIds, setLastUploadedIds] = useState<number[]>([])
|
||||||
|
|
||||||
const onDrop = useCallback(async (acceptedFiles) => {
|
const onDrop = useCallback(async (acceptedFiles) => {
|
||||||
if (acceptedFiles.length === 0) return
|
if (acceptedFiles.length === 0) return
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
|
const uploadedIds: number[] = []
|
||||||
try {
|
try {
|
||||||
for (const file of acceptedFiles) {
|
for (const file of acceptedFiles) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
await onUpload(formData)
|
const result = await onUpload(formData)
|
||||||
|
const fileObj = result?.file || result
|
||||||
|
if (fileObj?.id) uploadedIds.push(fileObj.id)
|
||||||
}
|
}
|
||||||
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
|
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
|
||||||
|
// Open assign modal for the last uploaded file
|
||||||
|
const lastId = uploadedIds[uploadedIds.length - 1]
|
||||||
|
if (lastId && (places.length > 0 || reservations.length > 0)) {
|
||||||
|
setAssignFileId(lastId)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('files.uploadError'))
|
toast.error(t('files.uploadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
}, [onUpload, toast, t])
|
}, [onUpload, toast, t, places, reservations])
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
@@ -130,24 +269,25 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
noClick: false,
|
noClick: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Paste support
|
|
||||||
const handlePaste = useCallback((e) => {
|
const handlePaste = useCallback((e) => {
|
||||||
|
if (!can('file_upload', trip)) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
const files = []
|
const pastedFiles = []
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
if (item.kind === 'file') {
|
if (item.kind === 'file') {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
if (file) files.push(file)
|
if (file) pastedFiles.push(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (files.length > 0) {
|
if (pastedFiles.length > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onDrop(files)
|
onDrop(pastedFiles)
|
||||||
}
|
}
|
||||||
}, [onDrop])
|
}, [onDrop])
|
||||||
|
|
||||||
const filteredFiles = files.filter(f => {
|
const filteredFiles = files.filter(f => {
|
||||||
|
if (filterType === 'starred') return !!f.starred
|
||||||
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
||||||
if (filterType === 'image') return isImage(f.mime_type)
|
if (filterType === 'image') return isImage(f.mime_type)
|
||||||
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
||||||
@@ -156,16 +296,33 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (!confirm(t('files.confirm.delete'))) return
|
|
||||||
try {
|
try {
|
||||||
await onDelete(id)
|
await onDelete(id)
|
||||||
toast.success(t('files.toast.deleted'))
|
toast.success(t('files.toast.trashed') || 'Moved to trash')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('files.toast.deleteError'))
|
toast.error(t('files.toast.deleteError'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [previewFile, setPreviewFile] = useState(null)
|
const [previewFile, setPreviewFile] = useState(null)
|
||||||
|
const [previewFileUrl, setPreviewFileUrl] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
if (previewFile) {
|
||||||
|
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
|
||||||
|
} else {
|
||||||
|
setPreviewFileUrl('')
|
||||||
|
}
|
||||||
|
}, [previewFile?.url])
|
||||||
|
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||||
|
try {
|
||||||
|
await filesApi.update(tripId, fileId, data)
|
||||||
|
refreshFiles()
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.assignError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openFile = (file) => {
|
const openFile = (file) => {
|
||||||
if (isImage(file.mime_type)) {
|
if (isImage(file.mime_type)) {
|
||||||
@@ -175,12 +332,314 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderFileRow = (file: TripFile, isTrash = false) => {
|
||||||
|
const FileIcon = getFileIcon(file.mime_type)
|
||||||
|
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,
|
||||||
|
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||||
|
transition: 'border-color 0.12s',
|
||||||
|
opacity: isTrash ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
{/* Icon or thumbnail */}
|
||||||
|
<div
|
||||||
|
onClick={() => !isTrash && openFile(file)}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||||
|
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage(file.mime_type)
|
||||||
|
? <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'
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
{file.uploaded_by_name && (
|
||||||
|
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
|
||||||
|
)}
|
||||||
|
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||||
|
<span
|
||||||
|
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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file.description && (
|
||||||
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{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'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions — always visible on mobile, hover on desktop */}
|
||||||
|
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||||
|
{isTrash ? (
|
||||||
|
<>
|
||||||
|
{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>}
|
||||||
|
{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 onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
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>
|
||||||
|
{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)} 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>
|
||||||
|
{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>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
||||||
{/* Lightbox */}
|
{/* Lightbox */}
|
||||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
||||||
|
|
||||||
{/* Datei-Vorschau Modal — portal to body to escape stacking context */}
|
{/* Assign modal */}
|
||||||
|
{assignFileId && ReactDOM.createPortal(
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={() => setAssignFileId(null)}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
|
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{files.find(f => f.id === assignFileId)?.original_name || ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px 12px 0' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('files.noteLabel') || 'Note'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('files.notePlaceholder')}
|
||||||
|
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
|
||||||
|
onBlur={e => {
|
||||||
|
const val = e.target.value.trim()
|
||||||
|
const file = files.find(f => f.id === assignFileId)
|
||||||
|
if (file && val !== (file.description || '')) {
|
||||||
|
handleAssign(file.id, { description: val } as any)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: 'auto', padding: 8 }}>
|
||||||
|
{(() => {
|
||||||
|
const file = files.find(f => f.id === assignFileId)
|
||||||
|
if (!file) return null
|
||||||
|
const assignedPlaceIds = new Set<number>()
|
||||||
|
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
|
||||||
|
for (const day of days) {
|
||||||
|
const da = assignments[String(day.id)] || []
|
||||||
|
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
|
||||||
|
if (dayPlaces.length > 0) {
|
||||||
|
dayGroups.push({ day, dayPlaces })
|
||||||
|
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||||
|
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 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('files.assignPlace')}
|
||||||
|
</div>
|
||||||
|
{dayGroups.map(({ day, dayPlaces }) => (
|
||||||
|
<div key={day.id}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||||
|
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
|
||||||
|
</div>
|
||||||
|
{dayPlaces.map(placeBtn)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{unassigned.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
|
||||||
|
{unassigned.map(placeBtn)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const bookingsSection = reservations.length > 0 && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<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 => {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasBoth = placesSection && bookingsSection
|
||||||
|
return (
|
||||||
|
<div className={hasBoth ? 'md:flex' : ''}>
|
||||||
|
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
|
||||||
|
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
|
||||||
|
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
|
||||||
|
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF preview modal */}
|
||||||
{previewFile && ReactDOM.createPortal(
|
{previewFile && ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
@@ -193,12 +652,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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
<button
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||||
<ExternalLink size={13} /> {t('files.openTab')}
|
<ExternalLink size={13} /> {t('files.openTab')}
|
||||||
</a>
|
</button>
|
||||||
<button onClick={() => setPreviewFile(null)}
|
<button onClick={() => setPreviewFile(null)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
@@ -208,13 +668,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<object
|
<object
|
||||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||||
type="application/pdf"
|
type="application/pdf"
|
||||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||||
title={previewFile.original_name}
|
title={previewFile.original_name}
|
||||||
>
|
>
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
<button onClick={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>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,172 +685,128 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('files.title')}</h2>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||||
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })}
|
{showTrash
|
||||||
|
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
|
||||||
|
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={toggleTrash} style={{
|
||||||
|
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
|
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
|
||||||
|
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload zone */}
|
{showTrash ? (
|
||||||
<div
|
/* Trash view */
|
||||||
{...getRootProps()}
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||||
style={{
|
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
<button onClick={handleEmptyTrash} style={{
|
||||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
|
||||||
}}
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
>
|
}}>
|
||||||
<input {...getInputProps()} />
|
{t('files.emptyTrash') || 'Empty Trash'}
|
||||||
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
</button>
|
||||||
{uploading ? (
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
)}
|
||||||
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
{loadingTrash ? (
|
||||||
{t('files.uploading')}
|
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-faint)' }}>
|
||||||
|
<div style={{ width: 20, height: 20, border: '2px solid var(--text-faint)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
) : trashFiles.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||||
|
<Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{trashFiles.map(file => renderFileRow(file, true))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Upload zone */}
|
||||||
|
{can('file_upload', trip) && <div
|
||||||
|
{...getRootProps()}
|
||||||
|
style={{
|
||||||
|
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||||
|
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||||
|
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
||||||
|
{uploading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
|
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||||
|
{t('files.uploading')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||||
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||||
|
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
||||||
|
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: t('files.filterAll') },
|
||||||
|
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
||||||
|
{ id: 'pdf', label: t('files.filterPdf') },
|
||||||
|
{ id: 'image', label: t('files.filterImages') },
|
||||||
|
{ id: 'doc', label: t('files.filterDocs') },
|
||||||
|
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||||
|
].map(tab => (
|
||||||
|
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
||||||
|
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||||
|
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||||
|
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
||||||
|
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
fontWeight: filterType === tab.id ? 600 : 400,
|
||||||
|
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
|
||||||
|
))}
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
||||||
|
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
|
||||||
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
|
||||||
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter tabs */}
|
{/* File list */}
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0 }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||||
{[
|
{filteredFiles.length === 0 ? (
|
||||||
{ id: 'all', label: t('files.filterAll') },
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||||
{ id: 'pdf', label: t('files.filterPdf') },
|
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||||
{ id: 'image', label: t('files.filterImages') },
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
||||||
{ id: 'doc', label: t('files.filterDocs') },
|
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
</div>
|
||||||
].map(tab => (
|
) : (
|
||||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
{filteredFiles.map(file => renderFileRow(file))}
|
||||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
</div>
|
||||||
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
)}
|
||||||
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
|
||||||
fontWeight: filterType === tab.id ? 600 : 400,
|
|
||||||
}}>{tab.label}</button>
|
|
||||||
))}
|
|
||||||
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
|
||||||
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File list */}
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
|
||||||
{filteredFiles.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
|
||||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
)}
|
||||||
{filteredFiles.map(file => {
|
|
||||||
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}`)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={file.id} style={{
|
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
|
||||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
|
||||||
transition: 'border-color 0.12s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
|
||||||
className="group"
|
|
||||||
>
|
|
||||||
{/* Icon or thumbnail */}
|
|
||||||
<div
|
|
||||||
onClick={() => openFile({ ...file, url: fileUrl })}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: 'pointer', overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isImage(file.mime_type)
|
|
||||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
: (() => {
|
|
||||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
|
||||||
const isPdf = file.mime_type === 'application/pdf'
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
|
||||||
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
onClick={() => openFile({ ...file, url: fileUrl })}
|
|
||||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{file.original_name}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
|
||||||
{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')}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{file.note_id && (
|
|
||||||
<SourceBadge
|
|
||||||
icon={StickyNote}
|
|
||||||
label={t('files.sourceCollab') || 'Collab Notes'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{file.description && !linkedReservation && (
|
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0, transition: 'opacity 0.12s' }} className="file-actions">
|
|
||||||
<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' }}
|
|
||||||
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' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
div:hover > .file-actions { opacity: 1 !important; }
|
@media (max-width: 767px) {
|
||||||
|
.file-actions button { padding: 8px !important; }
|
||||||
|
.file-actions svg { width: 18px !important; height: 18px !important; }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const texts: Record<string, DemoTexts> = {
|
|||||||
de: {
|
de: {
|
||||||
titleBefore: 'Willkommen bei ',
|
titleBefore: 'Willkommen bei ',
|
||||||
titleAfter: '',
|
titleAfter: '',
|
||||||
title: 'Willkommen zur NOMAD Demo',
|
title: 'Willkommen zur TREK Demo',
|
||||||
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||||
resetIn: 'Naechster Reset in',
|
resetIn: 'Naechster Reset in',
|
||||||
minutes: 'Minuten',
|
minutes: 'Minuten',
|
||||||
@@ -48,7 +48,7 @@ const texts: Record<string, DemoTexts> = {
|
|||||||
['Dokumente', 'Dateien an Reisen anhaengen'],
|
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||||||
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||||
],
|
],
|
||||||
whatIs: 'Was ist NOMAD?',
|
whatIs: 'Was ist TREK?',
|
||||||
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||||
selfHost: 'Open Source — ',
|
selfHost: 'Open Source — ',
|
||||||
selfHostLink: 'selbst hosten',
|
selfHostLink: 'selbst hosten',
|
||||||
@@ -57,7 +57,7 @@ const texts: Record<string, DemoTexts> = {
|
|||||||
en: {
|
en: {
|
||||||
titleBefore: 'Welcome to ',
|
titleBefore: 'Welcome to ',
|
||||||
titleAfter: '',
|
titleAfter: '',
|
||||||
title: 'Welcome to the NOMAD Demo',
|
title: 'Welcome to the TREK Demo',
|
||||||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||||||
resetIn: 'Next reset in',
|
resetIn: 'Next reset in',
|
||||||
minutes: 'minutes',
|
minutes: 'minutes',
|
||||||
@@ -80,12 +80,76 @@ const texts: Record<string, DemoTexts> = {
|
|||||||
['Documents', 'Attach files to trips'],
|
['Documents', 'Attach files to trips'],
|
||||||
['Widgets', 'Currency converter & timezones'],
|
['Widgets', 'Currency converter & timezones'],
|
||||||
],
|
],
|
||||||
whatIs: 'What is NOMAD?',
|
whatIs: 'What is TREK?',
|
||||||
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||||
selfHost: 'Open source — ',
|
selfHost: 'Open source — ',
|
||||||
selfHostLink: 'self-host it',
|
selfHostLink: 'self-host it',
|
||||||
close: 'Got 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',
|
||||||
|
},
|
||||||
|
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]
|
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||||
@@ -123,7 +187,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||||
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||||||
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
|
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
{t.titleBefore}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 18 }} />{t.titleAfter}
|
{t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,7 +215,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What is NOMAD */}
|
{/* What is TREK */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
|
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
|
||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
@@ -159,7 +223,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
<Map size={14} style={{ color: '#111827' }} />
|
<Map size={14} style={{ color: '#111827' }} />
|
||||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<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="NOMAD" style={{ height: 13, marginRight: -2 }} />?
|
{t.whatIs}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||||
@@ -213,7 +277,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||||
<Github size={13} />
|
<Github size={13} />
|
||||||
<span>{t.selfHost}</span>
|
<span>{t.selfHost}</span>
|
||||||
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
|
<a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
|
||||||
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
|
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
|
||||||
{t.selfHostLink}
|
{t.selfHostLink}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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: '#6366f1', color: '#fff' }}>
|
||||||
|
{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 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||||
|
</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: '#6366f1',
|
||||||
|
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 { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { useTranslation } from '../../i18n'
|
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 { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||||
|
|
||||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||||
|
|
||||||
@@ -28,29 +29,21 @@ interface Addon {
|
|||||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
|
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||||
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
|
|
||||||
const darkMode = settings.dark_mode
|
const darkMode = settings.dark_mode
|
||||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
|
||||||
const loadAddons = () => {
|
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||||
if (user) {
|
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||||
addonsApi.enabled().then(data => {
|
|
||||||
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useEffect(loadAddons, [user, location.pathname])
|
|
||||||
// Listen for addon changes from AddonManager
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => loadAddons()
|
if (user) loadAddons()
|
||||||
window.addEventListener('addons-changed', handler)
|
}, [user, location.pathname])
|
||||||
return () => window.removeEventListener('addons-changed', handler)
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('../../api/client').then(({ authApi }) => {
|
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(() => {})
|
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 (
|
return (
|
||||||
<nav style={{
|
<nav style={{
|
||||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
||||||
@@ -91,8 +90,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
|
<Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
|
||||||
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="NOMAD" className="sm:hidden" style={{ height: 22, width: 22 }} />
|
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="TREK" className="sm:hidden" style={{ height: 22, width: 22 }} />
|
||||||
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="NOMAD" className="hidden sm:block" style={{ height: 28 }} />
|
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Global addon nav items */}
|
{/* Global addon nav items */}
|
||||||
@@ -124,7 +123,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<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>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -165,6 +164,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Notification bell */}
|
||||||
|
{user && <InAppNotificationBell />}
|
||||||
|
|
||||||
{/* User menu */}
|
{/* User menu */}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -230,9 +232,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
</button>
|
</button>
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
<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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 10, opacity: 0.5 }} />
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
<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>
|
||||||
</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 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 MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||||
import { mapsApi } from '../../api/client'
|
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'
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
// Fix default marker icons for vite
|
// Fix default marker icons for vite
|
||||||
@@ -26,7 +34,12 @@ function escAttr(s) {
|
|||||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconCache = new Map<string, L.DivIcon>()
|
||||||
|
|
||||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
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 size = isSelected ? 44 : 36
|
||||||
const borderColor = isSelected ? '#111827' : 'white'
|
const borderColor = isSelected ? '#111827' : 'white'
|
||||||
const borderWidth = isSelected ? 3 : 2.5
|
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 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)'
|
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||||
const bgColor = place.category_color || '#6b7280'
|
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 = ''
|
let badgeHtml = ''
|
||||||
if (orderNumbers && orderNumbers.length > 0) {
|
if (orderNumbers && orderNumbers.length > 0) {
|
||||||
const label = orderNumbers.join(' · ')
|
const label = orderNumbers.join(' · ')
|
||||||
@@ -54,18 +66,22 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
">${label}</span>`
|
">${label}</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (place.image_url) {
|
// Base64 data URL thumbnails — no external image fetch during zoom
|
||||||
return L.divIcon({
|
// 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: '',
|
className: '',
|
||||||
html: `<div style="
|
html: `<div style="
|
||||||
width:${size}px;height:${size}px;border-radius:50%;
|
width:${size}px;height:${size}px;
|
||||||
border:${borderWidth}px solid ${borderColor};
|
cursor:pointer;position:relative;
|
||||||
box-shadow:${shadow};
|
|
||||||
overflow:visible;background:${bgColor};
|
|
||||||
cursor:pointer;flex-shrink:0;position:relative;
|
|
||||||
">
|
">
|
||||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
<div style="
|
||||||
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
|
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>
|
</div>
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -73,9 +89,11 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
tooltipAnchor: [size / 2 + 6, 0],
|
tooltipAnchor: [size / 2 + 6, 0],
|
||||||
})
|
})
|
||||||
|
iconCache.set(cacheKey, imgIcon)
|
||||||
|
return imgIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
return L.divIcon({
|
const fallbackIcon = L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
html: `<div style="
|
html: `<div style="
|
||||||
width:${size}px;height:${size}px;border-radius:50%;
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
@@ -84,14 +102,17 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
background:${bgColor};
|
background:${bgColor};
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
cursor:pointer;position:relative;
|
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}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
tooltipAnchor: [size / 2 + 6, 0],
|
tooltipAnchor: [size / 2 + 6, 0],
|
||||||
})
|
})
|
||||||
|
iconCache.set(cacheKey, fallbackIcon)
|
||||||
|
return fallbackIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectionControllerProps {
|
interface SelectionControllerProps {
|
||||||
@@ -107,20 +128,14 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
// Fit all day places into view (so you see context), but ensure selected is visible
|
// Pan to the selected place without changing zoom
|
||||||
const toFit = dayPlaces.length > 0 ? dayPlaces : places.filter(p => p.id === selectedPlaceId)
|
const selected = places.find(p => p.id === selectedPlaceId)
|
||||||
const withCoords = toFit.filter(p => p.lat && p.lng)
|
if (selected?.lat && selected?.lng) {
|
||||||
if (withCoords.length > 0) {
|
map.panTo([selected.lat, selected.lng], { animate: true })
|
||||||
try {
|
|
||||||
const bounds = L.latLngBounds(withCoords.map(p => [p.lat, p.lng]))
|
|
||||||
if (bounds.isValid()) {
|
|
||||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prev.current = selectedPlaceId
|
prev.current = selectedPlaceId
|
||||||
}, [selectedPlaceId, places, dayPlaces, paddingOpts, map])
|
}, [selectedPlaceId, places, map])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -172,6 +187,16 @@ interface MapClickHandlerProps {
|
|||||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
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) {
|
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,6 +207,16 @@ function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
|
||||||
|
const map = useMap()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onContextMenu) return
|
||||||
|
map.on('contextmenu', onContextMenu)
|
||||||
|
return () => map.off('contextmenu', onContextMenu)
|
||||||
|
}, [map, onContextMenu])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// ── Route travel time label ──
|
// ── Route travel time label ──
|
||||||
interface RouteLabelProps {
|
interface RouteLabelProps {
|
||||||
midpoint: [number, number]
|
midpoint: [number, number]
|
||||||
@@ -233,9 +268,99 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Module-level photo cache shared with PlaceAvatar
|
// Module-level photo cache shared with PlaceAvatar
|
||||||
const mapPhotoCache = new Map()
|
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 = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
route = null,
|
route = null,
|
||||||
@@ -243,6 +368,7 @@ export function MapView({
|
|||||||
selectedPlaceId = null,
|
selectedPlaceId = null,
|
||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
onMapClick,
|
onMapClick,
|
||||||
|
onMapContextMenu = null,
|
||||||
center = [48.8566, 2.3522],
|
center = [48.8566, 2.3522],
|
||||||
zoom = 10,
|
zoom = 10,
|
||||||
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||||
@@ -262,30 +388,110 @@ export function MapView({
|
|||||||
const right = rightWidth + 40
|
const right = rightWidth + 40
|
||||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||||
}, [leftWidth, rightWidth, hasInspector])
|
}, [leftWidth, rightWidth, hasInspector])
|
||||||
const [photoUrls, setPhotoUrls] = useState({})
|
|
||||||
|
|
||||||
// Fetch Google photos for places that have google_place_id but no image_url
|
// 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(() => {
|
useEffect(() => {
|
||||||
places.forEach(place => {
|
if (!places || places.length === 0) return
|
||||||
if (place.image_url || !place.google_place_id) return
|
const cleanups: (() => void)[] = []
|
||||||
if (mapPhotoCache.has(place.google_place_id)) {
|
|
||||||
const cached = mapPhotoCache.get(place.google_place_id)
|
const setThumb = (cacheKey: string, thumb: string) => {
|
||||||
if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached }))
|
iconCache.clear()
|
||||||
return
|
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) continue
|
||||||
|
|
||||||
|
const cached = getCached(cacheKey)
|
||||||
|
if (cached?.thumbDataUrl) {
|
||||||
|
setThumb(cacheKey, cached.thumbDataUrl)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
mapsApi.placePhoto(place.google_place_id)
|
|
||||||
.then(data => {
|
// Subscribe for when thumb becomes available
|
||||||
if (data.photoUrl) {
|
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||||
mapPhotoCache.set(place.google_place_id, data.photoUrl)
|
|
||||||
setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl }))
|
// Always fetch through API — returns fresh URL + converts to base64
|
||||||
}
|
if (!cached && !isLoading(cacheKey)) {
|
||||||
})
|
const photoId = place.google_place_id || place.osm_id
|
||||||
.catch(() => { mapPhotoCache.set(place.google_place_id, null) })
|
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 (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
|
id="trek-map"
|
||||||
center={center}
|
center={center}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
zoomControl={false}
|
zoomControl={false}
|
||||||
@@ -296,79 +502,32 @@ export function MapView({
|
|||||||
url={tileUrl}
|
url={tileUrl}
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
maxZoom={19}
|
maxZoom={19}
|
||||||
|
keepBuffer={8}
|
||||||
|
updateWhenZooming={false}
|
||||||
|
updateWhenIdle={true}
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MapController center={center} zoom={zoom} />
|
<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} />
|
||||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||||
<MapClickHandler onClick={onMapClick} />
|
<MapClickHandler onClick={onMapClick} />
|
||||||
|
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||||
|
<LocationTracker />
|
||||||
|
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
chunkedLoading
|
chunkedLoading
|
||||||
|
chunkInterval={30}
|
||||||
|
chunkDelay={0}
|
||||||
maxClusterRadius={30}
|
maxClusterRadius={30}
|
||||||
disableClusteringAtZoom={11}
|
disableClusteringAtZoom={11}
|
||||||
spiderfyOnMaxZoom
|
spiderfyOnMaxZoom
|
||||||
showCoverageOnHover={false}
|
showCoverageOnHover={false}
|
||||||
zoomToBoundsOnClick
|
zoomToBoundsOnClick
|
||||||
singleMarkerMode
|
animate={false}
|
||||||
iconCreateFunction={(cluster) => {
|
iconCreateFunction={clusterIconCreateFunction}
|
||||||
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.map((place) => {
|
{markers}
|
||||||
const isSelected = place.id === selectedPlaceId
|
|
||||||
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || 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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{route && route.length > 1 && (
|
{route && route.length > 1 && (
|
||||||
@@ -385,6 +544,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>
|
</MapContainer>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function calculateRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
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 })
|
const response = await fetch(url, { signal })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,865 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
|
||||||
|
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||||
|
const [src, setSrc] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
let revoke = ''
|
||||||
|
fetchImageAsBlob(baseUrl).then(blobUrl => {
|
||||||
|
revoke = blobUrl
|
||||||
|
setSrc(blobUrl)
|
||||||
|
})
|
||||||
|
return () => { if (revoke) URL.revokeObjectURL(revoke) }
|
||||||
|
}, [baseUrl])
|
||||||
|
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TripPhoto {
|
||||||
|
immich_asset_id: string
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
shared: number
|
||||||
|
added_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImmichAsset {
|
||||||
|
id: string
|
||||||
|
takenAt: string
|
||||||
|
city: string | null
|
||||||
|
country: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoriesPanelProps {
|
||||||
|
tripId: number
|
||||||
|
startDate: string | null
|
||||||
|
endDate: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Trip photos (saved selections)
|
||||||
|
const [tripPhotos, setTripPhotos] = useState<TripPhoto[]>([])
|
||||||
|
|
||||||
|
// Photo picker
|
||||||
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
|
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
|
||||||
|
const [pickerLoading, setPickerLoading] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Confirm share popup
|
||||||
|
const [showConfirmShare, setShowConfirmShare] = useState(false)
|
||||||
|
|
||||||
|
// Filters & sort
|
||||||
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
|
const [locationFilter, setLocationFilter] = useState('')
|
||||||
|
|
||||||
|
// Album linking
|
||||||
|
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
|
||||||
|
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
|
||||||
|
const [albumsLoading, setAlbumsLoading] = useState(false)
|
||||||
|
const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||||
|
const [syncing, setSyncing] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const loadAlbumLinks = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||||
|
setAlbumLinks(res.data.links || [])
|
||||||
|
} catch { setAlbumLinks([]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAlbumPicker = async () => {
|
||||||
|
setShowAlbumPicker(true)
|
||||||
|
setAlbumsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/integrations/immich/albums')
|
||||||
|
setAlbums(res.data.albums || [])
|
||||||
|
} catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) }
|
||||||
|
finally { setAlbumsLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName })
|
||||||
|
setShowAlbumPicker(false)
|
||||||
|
await loadAlbumLinks()
|
||||||
|
// Auto-sync after linking
|
||||||
|
const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||||
|
const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
|
||||||
|
if (newLink) await syncAlbum(newLink.id)
|
||||||
|
} catch { toast.error(t('memories.error.linkAlbum')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlinkAlbum = async (linkId: number) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
|
||||||
|
loadAlbumLinks()
|
||||||
|
} catch { toast.error(t('memories.error.unlinkAlbum')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncAlbum = async (linkId: number) => {
|
||||||
|
setSyncing(linkId)
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
|
||||||
|
await loadAlbumLinks()
|
||||||
|
await loadPhotos()
|
||||||
|
} catch { toast.error(t('memories.error.syncAlbum')) }
|
||||||
|
finally { setSyncing(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightbox
|
||||||
|
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||||
|
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||||
|
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||||
|
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||||
|
const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('')
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitial()
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
// WebSocket: reload photos when another user adds/removes/shares
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => loadPhotos()
|
||||||
|
window.addEventListener('memories:updated', handler)
|
||||||
|
return () => window.removeEventListener('memories:updated', handler)
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const loadPhotos = async () => {
|
||||||
|
try {
|
||||||
|
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
|
||||||
|
setTripPhotos(photosRes.data.photos || [])
|
||||||
|
} catch {
|
||||||
|
setTripPhotos([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInitial = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const statusRes = await apiClient.get('/integrations/immich/status')
|
||||||
|
setConnected(statusRes.data.connected)
|
||||||
|
} catch {
|
||||||
|
setConnected(false)
|
||||||
|
}
|
||||||
|
await loadPhotos()
|
||||||
|
await loadAlbumLinks()
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [pickerDateFilter, setPickerDateFilter] = useState(true)
|
||||||
|
|
||||||
|
const openPicker = async () => {
|
||||||
|
setShowPicker(true)
|
||||||
|
setPickerLoading(true)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setPickerDateFilter(!!(startDate && endDate))
|
||||||
|
await loadPickerPhotos(!!(startDate && endDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPickerPhotos = async (useDate: boolean) => {
|
||||||
|
setPickerLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post('/integrations/immich/search', {
|
||||||
|
from: useDate && startDate ? startDate : undefined,
|
||||||
|
to: useDate && endDate ? endDate : undefined,
|
||||||
|
})
|
||||||
|
setPickerPhotos(res.data.assets || [])
|
||||||
|
} catch {
|
||||||
|
setPickerPhotos([])
|
||||||
|
toast.error(t('memories.error.loadPhotos'))
|
||||||
|
} finally {
|
||||||
|
setPickerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePickerSelect = (id: string) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSelection = () => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
setShowConfirmShare(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeAddPhotos = async () => {
|
||||||
|
setShowConfirmShare(false)
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
|
||||||
|
asset_ids: [...selectedIds],
|
||||||
|
shared: true,
|
||||||
|
})
|
||||||
|
setShowPicker(false)
|
||||||
|
clearImageQueue()
|
||||||
|
loadInitial()
|
||||||
|
} catch { toast.error(t('memories.error.addPhotos')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const removePhoto = async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||||
|
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||||
|
} catch { toast.error(t('memories.error.removePhoto')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleSharing = async (assetId: string, shared: boolean) => {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
|
||||||
|
setTripPhotos(prev => prev.map(p =>
|
||||||
|
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||||
|
))
|
||||||
|
} catch { toast.error(t('memories.error.toggleSharing')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const thumbnailBaseUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
|
||||||
|
|
||||||
|
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||||
|
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||||
|
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
|
||||||
|
|
||||||
|
// Unique locations for filter
|
||||||
|
const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort()
|
||||||
|
|
||||||
|
// Apply filter + sort
|
||||||
|
const allVisible = allVisibleRaw
|
||||||
|
.filter(p => !locationFilter || p.city === locationFilter)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = new Date(a.added_at || 0).getTime()
|
||||||
|
const db = new Date(b.added_at || 0).getTime()
|
||||||
|
return sortAsc ? da - db : db - da
|
||||||
|
})
|
||||||
|
|
||||||
|
const font: React.CSSProperties = {
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', ...font }}>
|
||||||
|
<div className="w-8 h-8 border-2 rounded-full animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not connected ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!connected && allVisible.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 40, textAlign: 'center', ...font }}>
|
||||||
|
<Camera size={40} style={{ color: 'var(--text-faint)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.notConnected')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
|
||||||
|
{t('memories.notConnectedHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Album Picker Modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (showAlbumPicker) {
|
||||||
|
const linkedIds = new Set(albumLinks.map(l => l.immich_album_id))
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.selectAlbum')}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setShowAlbumPicker(false)}
|
||||||
|
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{albumsLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<div style={{ width: 24, height: 24, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
) : albums.length === 0 ? (
|
||||||
|
<p style={{ textAlign: 'center', padding: 40, fontSize: 13, color: 'var(--text-faint)' }}>
|
||||||
|
{t('memories.noAlbums')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{albums.map(album => {
|
||||||
|
const isLinked = linkedIds.has(album.id)
|
||||||
|
return (
|
||||||
|
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
|
||||||
|
disabled={isLinked}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
|
||||||
|
borderRadius: 10, border: 'none', cursor: isLinked ? 'default' : 'pointer',
|
||||||
|
background: isLinked ? 'var(--bg-tertiary)' : 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||||
|
opacity: isLinked ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isLinked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!isLinked) e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<FolderOpen size={20} color="var(--text-muted)" />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{album.albumName}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||||
|
{album.assetCount} {t('memories.photos')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLinked ? (
|
||||||
|
<Check size={16} color="var(--text-faint)" />
|
||||||
|
) : (
|
||||||
|
<Link2 size={16} color="var(--text-muted)" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (showPicker) {
|
||||||
|
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
{/* Picker header */}
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.selectPhotos')}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => { clearImageQueue(); setShowPicker(false) }}
|
||||||
|
style={{ padding: '7px 14px', 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 onClick={confirmSelection} disabled={selectedIds.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600,
|
||||||
|
cursor: selectedIds.size > 0 ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||||
|
background: selectedIds.size > 0 ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: selectedIds.size > 0 ? 'var(--bg-primary)' : 'var(--text-faint)',
|
||||||
|
}}>
|
||||||
|
{selectedIds.size > 0 ? t('memories.addSelected', { count: selectedIds.size }) : t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{startDate && endDate && (
|
||||||
|
<button onClick={() => { if (!pickerDateFilter) { setPickerDateFilter(true); loadPickerPhotos(true) } }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
border: '1px solid', transition: 'all 0.15s',
|
||||||
|
background: pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('memories.tripDates')} ({startDate ? new Date(startDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : ''} — {endDate ? new Date(endDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : ''})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { if (pickerDateFilter || !startDate) { setPickerDateFilter(false); loadPickerPhotos(false) } }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
border: '1px solid', transition: 'all 0.15s',
|
||||||
|
background: !pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('memories.allPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{selectedIds.size} {t('memories.selected')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picker grid */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{pickerLoading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 60 }}>
|
||||||
|
<div className="w-7 h-7 border-2 rounded-full animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
) : pickerPhotos.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
<Camera size={36} style={{ color: 'var(--text-faint)', margin: '0 auto 10px', display: 'block' }} />
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>{t('memories.noPhotos')}</p>
|
||||||
|
</div>
|
||||||
|
) : (() => {
|
||||||
|
// Group photos by month
|
||||||
|
const byMonth: Record<string, ImmichAsset[]> = {}
|
||||||
|
for (const asset of pickerPhotos) {
|
||||||
|
const d = asset.takenAt ? new Date(asset.takenAt) : null
|
||||||
|
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
|
||||||
|
if (!byMonth[key]) byMonth[key] = []
|
||||||
|
byMonth[key].push(asset)
|
||||||
|
}
|
||||||
|
const sortedMonths = Object.keys(byMonth).sort().reverse()
|
||||||
|
|
||||||
|
return sortedMonths.map(month => (
|
||||||
|
<div key={month} style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>
|
||||||
|
{month !== 'unknown'
|
||||||
|
? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
|
||||||
|
{byMonth[month].map(asset => {
|
||||||
|
const isSelected = selectedIds.has(asset.id)
|
||||||
|
const isAlready = alreadyAdded.has(asset.id)
|
||||||
|
return (
|
||||||
|
<div key={asset.id}
|
||||||
|
onClick={() => !isAlready && togglePickerSelect(asset.id)}
|
||||||
|
style={{
|
||||||
|
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
||||||
|
cursor: isAlready ? 'default' : 'pointer',
|
||||||
|
opacity: isAlready ? 0.3 : 1,
|
||||||
|
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||||
|
outlineOffset: -3,
|
||||||
|
}}>
|
||||||
|
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 4, right: 4, width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Check size={13} color="var(--bg-primary)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAlready && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', fontSize: 10, color: 'white', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{t('memories.alreadyAdded')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm share popup (inside picker) */}
|
||||||
|
{showConfirmShare && (
|
||||||
|
<div onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||||
|
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.confirmShareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeAddPhotos}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('memories.confirmShareButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Gallery ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.title')}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||||
|
{allVisible.length} {t('memories.photosFound')}
|
||||||
|
{othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{connected && (
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button onClick={openAlbumPicker}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)',
|
||||||
|
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Link2 size={13} /> {t('memories.linkAlbum')}
|
||||||
|
</button>
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={14} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Linked Albums */}
|
||||||
|
{albumLinks.length > 0 && (
|
||||||
|
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{albumLinks.map(link => (
|
||||||
|
<div key={link.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 8,
|
||||||
|
background: 'var(--bg-tertiary)', fontSize: 11, color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<FolderOpen size={11} />
|
||||||
|
<span style={{ fontWeight: 500 }}>{link.album_name}</span>
|
||||||
|
{link.username !== currentUser?.username && <span style={{ color: 'var(--text-faint)' }}>({link.username})</span>}
|
||||||
|
<button onClick={() => syncAlbum(link.id)} disabled={syncing === link.id} title={t('memories.syncAlbum')}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||||
|
<RefreshCw size={11} style={{ animation: syncing === link.id ? 'spin 1s linear infinite' : 'none' }} />
|
||||||
|
</button>
|
||||||
|
{link.user_id === currentUser?.id && (
|
||||||
|
<button onClick={() => unlinkAlbum(link.id)} title={t('memories.unlinkAlbum')}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter & Sort bar */}
|
||||||
|
{allVisibleRaw.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => setSortAsc(v => !v)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-card)',
|
||||||
|
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<ArrowUpDown size={11} /> {sortAsc ? t('memories.oldest') : t('memories.newest')}
|
||||||
|
</button>
|
||||||
|
{locations.length > 1 && (
|
||||||
|
<select value={locationFilter} onChange={e => setLocationFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
|
background: 'var(--bg-card)', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||||
|
cursor: 'pointer', outline: 'none',
|
||||||
|
}}>
|
||||||
|
<option value="">{t('memories.allLocations')}</option>
|
||||||
|
{locations.map(loc => <option key={loc} value={loc}>{loc}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gallery */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{allVisible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
<Camera size={40} style={{ color: 'var(--text-faint)', margin: '0 auto 12px', display: 'block' }} />
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>
|
||||||
|
{t('memories.noPhotos')}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
|
||||||
|
{t('memories.noPhotosHint')}
|
||||||
|
</p>
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '9px 18px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={15} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 6 }}>
|
||||||
|
{allVisible.map(photo => {
|
||||||
|
const isOwn = photo.user_id === currentUser?.id
|
||||||
|
return (
|
||||||
|
<div key={photo.immich_asset_id} className="group"
|
||||||
|
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||||
|
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||||
|
setLightboxOriginalSrc('')
|
||||||
|
fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc)
|
||||||
|
setLightboxInfoLoading(true)
|
||||||
|
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||||
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||||
|
|
||||||
|
{/* Other user's avatar */}
|
||||||
|
{!isOwn && (
|
||||||
|
<div className="memories-avatar" style={{ position: 'absolute', bottom: 6, left: 6, zIndex: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: `hsl(${photo.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||||
|
border: '2px solid white', boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
{photo.username[0]}
|
||||||
|
</div>
|
||||||
|
<div className="memories-avatar-tooltip" style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginBottom: 6, padding: '3px 8px', borderRadius: 6,
|
||||||
|
background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
|
||||||
|
}}>
|
||||||
|
{photo.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Own photo actions (hover) */}
|
||||||
|
{isOwn && (
|
||||||
|
<div className="opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 3, transition: 'opacity 0.15s' }}>
|
||||||
|
<button onClick={e => { e.stopPropagation(); toggleSharing(photo.immich_asset_id, !photo.shared) }}
|
||||||
|
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<X size={12} color="white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not shared indicator */}
|
||||||
|
{isOwn && !photo.shared && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 6, right: 6, padding: '2px 6px', borderRadius: 6,
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
fontSize: 9, color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<EyeOff size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||||
|
{t('memories.private')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.memories-avatar:hover .memories-avatar-tooltip { opacity: 1 !important; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Confirm share popup */}
|
||||||
|
{showConfirmShare && (
|
||||||
|
<div onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||||
|
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.confirmShareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeAddPhotos}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('memories.confirmShareButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightboxId && lightboxUserId && (
|
||||||
|
<div onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 100,
|
||||||
|
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<X size={20} color="white" />
|
||||||
|
</button>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||||
|
<img
|
||||||
|
src={lightboxOriginalSrc}
|
||||||
|
alt=""
|
||||||
|
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info panel — liquid glass */}
|
||||||
|
{lightboxInfo && (
|
||||||
|
<div style={{
|
||||||
|
width: 240, flexShrink: 0, borderRadius: 16, padding: 18,
|
||||||
|
background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', color: 'white',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 14, maxHeight: '100%', overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{/* Date */}
|
||||||
|
{lightboxInfo.takenAt && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Date</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{(lightboxInfo.city || lightboxInfo.country) && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>
|
||||||
|
<MapPin size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />Location
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Camera */}
|
||||||
|
{lightboxInfo.camera && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Camera</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 500 }}>{lightboxInfo.camera}</div>
|
||||||
|
{lightboxInfo.lens && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 2 }}>{lightboxInfo.lens}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
{lightboxInfo.focalLength && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Focal</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.focalLength}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.aperture && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Aperture</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.aperture}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.shutter && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shutter</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.shutter}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.iso && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>ISO</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.iso}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolution & File */}
|
||||||
|
{(lightboxInfo.width || lightboxInfo.fileName) && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 10 }}>
|
||||||
|
{lightboxInfo.width && lightboxInfo.height && (
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>{lightboxInfo.width} × {lightboxInfo.height}</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.fileSize && (
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lightboxInfoLoading && (
|
||||||
|
<div style={{ width: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: 'rgba(255,255,255,0.2)', borderTopColor: 'white' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Unread dot */}
|
||||||
|
{!notification.is_read && (
|
||||||
|
<div className="absolute left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full" style={{ background: '#6366f1' }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 = '#6366f1' }}
|
||||||
|
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'
|
||||||
|
? '#6366f1'
|
||||||
|
: 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ function noteIconSvg(iconId) {
|
|||||||
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
return _renderToStaticMarkup(createElement(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) {
|
||||||
|
if (!_renderToStaticMarkup) return ''
|
||||||
|
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
|
||||||
|
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }))
|
||||||
|
}
|
||||||
|
|
||||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||||
@@ -54,15 +61,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
|
|||||||
|
|
||||||
function shortDate(d, locale) {
|
function shortDate(d, locale) {
|
||||||
if (!d) return ''
|
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) {
|
function longDateRange(days, locale) {
|
||||||
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
|
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
|
||||||
if (!dd.length) return null
|
if (!dd.length) return null
|
||||||
const f = new Date(dd[0].date + 'T00:00:00')
|
const f = new Date(dd[0].date + 'T00:00:00Z')
|
||||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
|
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
|
||||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
|
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) {
|
function dayCost(assignments, dayId, locale) {
|
||||||
@@ -96,13 +103,14 @@ interface downloadTripPDFProps {
|
|||||||
assignments: AssignmentsMap
|
assignments: AssignmentsMap
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
dayNotes: DayNotesMap
|
dayNotes: DayNotesMap
|
||||||
|
reservations?: any[]
|
||||||
t: (key: string, params?: Record<string, string | number>) => string
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
locale: 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()
|
await ensureRenderer()
|
||||||
const loc = _locale || 'de-DE'
|
const loc = _locale || undefined
|
||||||
const tr = _t || (k => k)
|
const tr = _t || (k => k)
|
||||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||||
const range = longDateRange(sorted, loc)
|
const range = longDateRange(sorted, loc)
|
||||||
@@ -123,15 +131,46 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||||
const cost = dayCost(assignments, day.id, loc)
|
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 = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
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 }))
|
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)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
|
|
||||||
let pi = 0
|
let pi = 0
|
||||||
const itemsHtml = merged.length === 0
|
const itemsHtml = merged.length === 0
|
||||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||||
: merged.map(item => {
|
: 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') {
|
if (item.type === 'note') {
|
||||||
const note = item.data
|
const note = item.data
|
||||||
return `
|
return `
|
||||||
@@ -165,7 +204,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
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('')
|
].filter(Boolean).join('')
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -190,7 +229,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
<div class="day-section${di > 0 ? ' page-break' : ''}">
|
<div class="day-section${di > 0 ? ' page-break' : ''}">
|
||||||
<div class="day-header">
|
<div class="day-header">
|
||||||
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
|
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
|
||||||
<span class="day-title">${escHtml(day.title || `Tag ${day.day_number}`)}</span>
|
<span class="day-title">${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))}</span>
|
||||||
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
|
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
|
||||||
${cost ? `<span class="day-cost">${cost}</span>` : ''}
|
${cost ? `<span class="day-cost">${cost}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +238,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
}).join('')
|
}).join('')
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="${loc.split('-')[0]}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<base href="${window.location.origin}/">
|
<base href="${window.location.origin}/">
|
||||||
@@ -377,7 +416,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
||||||
</div>
|
</div>
|
||||||
${totalCost > 0 ? `<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 class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PhotoLightbox } from './PhotoLightbox'
|
|||||||
import { PhotoUpload } from './PhotoUpload'
|
import { PhotoUpload } from './PhotoUpload'
|
||||||
import { Upload, Camera } from 'lucide-react'
|
import { Upload, Camera } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import type { Photo, Place, Day } from '../../types'
|
import type { Photo, Place, Day } from '../../types'
|
||||||
|
|
||||||
interface PhotoGalleryProps {
|
interface PhotoGalleryProps {
|
||||||
@@ -17,7 +17,7 @@ interface PhotoGalleryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: 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 [lightboxIndex, setLightboxIndex] = useState(null)
|
||||||
const [showUpload, setShowUpload] = useState(false)
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
const [filterDayId, setFilterDayId] = useState('')
|
const [filterDayId, setFilterDayId] = useState('')
|
||||||
@@ -53,7 +53,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
<div style={{ marginRight: 'auto' }}>
|
<div style={{ marginRight: 'auto' }}>
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
<option value="">{t('photos.allDays')}</option>
|
<option value="">{t('photos.allDays')}</option>
|
||||||
{(days || []).map(day => (
|
{(days || []).map(day => (
|
||||||
<option key={day.id} value={day.id}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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"
|
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" />
|
<Upload className="w-4 h-4" />
|
||||||
Fotos hochladen
|
{t('common.upload')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
style={{ display: 'inline-flex', margin: '0 auto' }}
|
style={{ display: 'inline-flex', margin: '0 auto' }}
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Fotos hochladen
|
{t('common.upload')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -146,7 +146,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={showUpload}
|
isOpen={showUpload}
|
||||||
onClose={() => setShowUpload(false)}
|
onClose={() => setShowUpload(false)}
|
||||||
title="Fotos hochladen"
|
title={t('common.upload')}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<PhotoUpload
|
<PhotoUpload
|
||||||
@@ -211,7 +211,7 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr, locale) {
|
||||||
if (!dateStr) return ''
|
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 ''
|
if (!dateStr) return ''
|
||||||
try {
|
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 '' }
|
} 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_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' }
|
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 { weatherApi, accommodationsApi } from '../../api/client'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
const WEATHER_ICON_MAP = {
|
const WEATHER_ICON_MAP = {
|
||||||
@@ -50,17 +52,24 @@ interface DayDetailPanelProps {
|
|||||||
lng: number | null
|
lng: number | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onAccommodationChange: () => void
|
onAccommodationChange: () => void
|
||||||
|
leftWidth?: number
|
||||||
|
rightWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
||||||
const { t, language } = useTranslation()
|
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 isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
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 fmtTime = (v) => formatTime12(v, is12h)
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const [weather, setWeather] = useState(null)
|
const [weather, setWeather] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [accommodation, setAccommodation] = useState(null)
|
const [accommodation, setAccommodation] = useState(null)
|
||||||
|
const [dayAccommodations, setDayAccommodations] = useState<any[]>([])
|
||||||
const [accommodations, setAccommodations] = useState([])
|
const [accommodations, setAccommodations] = useState([])
|
||||||
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
||||||
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
||||||
@@ -81,10 +90,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
accommodationsApi.list(tripId)
|
accommodationsApi.list(tripId)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setAccommodations(data.accommodations || [])
|
setAccommodations(data.accommodations || [])
|
||||||
const acc = (data.accommodations || []).find(a =>
|
const allForDay = (data.accommodations || []).filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||||
)
|
)
|
||||||
setAccommodation(acc || null)
|
setDayAccommodations(allForDay)
|
||||||
|
setAccommodation(allForDay[0] || null)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [tripId, day?.id])
|
}, [tripId, day?.id])
|
||||||
@@ -106,8 +116,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
check_out: hotelForm.check_out || null,
|
check_out: hotelForm.check_out || null,
|
||||||
confirmation: hotelForm.confirmation || null,
|
confirmation: hotelForm.confirmation || null,
|
||||||
})
|
})
|
||||||
setAccommodation(data.accommodation)
|
const newAcc = data.accommodation
|
||||||
setAccommodations(prev => [...prev, 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)
|
setShowHotelPicker(false)
|
||||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
@@ -127,7 +142,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
if (!accommodation) return
|
if (!accommodation) return
|
||||||
try {
|
try {
|
||||||
await accommodationsApi.delete(tripId, accommodation.id)
|
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)
|
setAccommodation(null)
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -135,16 +154,16 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
|
|
||||||
if (!day) return null
|
if (!day) return null
|
||||||
|
|
||||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(
|
||||||
language === 'de' ? 'de-DE' : 'en-US',
|
getLocaleForLanguage(language),
|
||||||
{ weekday: 'long', day: 'numeric', month: 'long' }
|
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
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={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
@@ -268,7 +287,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
</div>
|
</div>
|
||||||
{r.reservation_time?.includes('T') && (
|
{r.reservation_time?.includes('T') && (
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
<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)}`}
|
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -287,66 +306,115 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
|
||||||
|
|
||||||
{accommodation ? (
|
{dayAccommodations.length > 0 ? (
|
||||||
<div style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{/* Hotel header */}
|
{dayAccommodations.map(acc => {
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px' }}>
|
const isCheckInDay = acc.start_day_id === day.id
|
||||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
const isCheckOutDay = acc.end_day_id === day.id
|
||||||
{accommodation.place_image ? (
|
const isMiddleDay = !isCheckInDay && !isCheckOutDay
|
||||||
<img src={accommodation.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
|
const dayLabel = isCheckInDay && isCheckOutDay ? t('day.checkIn') + ' & ' + t('day.checkOut')
|
||||||
) : (
|
: isCheckInDay ? t('day.checkIn')
|
||||||
<Hotel size={16} style={{ color: 'var(--text-muted)' }} />
|
: isCheckOutDay ? t('day.checkOut')
|
||||||
)}
|
: null
|
||||||
</div>
|
const linked = reservations.find(r => r.accommodation_id === acc.id)
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
const confirmed = linked?.status === 'confirmed'
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_name}</div>
|
|
||||||
{accommodation.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_address}</div>}
|
return (
|
||||||
</div>
|
<div key={acc.id} style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}>
|
||||||
<button onClick={handleRemoveAccommodation} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
{/* Day label */}
|
||||||
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
{dayLabel && (
|
||||||
</button>
|
<div style={{ padding: '4px 12px 0', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
</div>
|
{isCheckInDay && <LogIn size={9} style={{ color: '#22c55e' }} />}
|
||||||
{/* Details row */}
|
{isCheckOutDay && !isCheckInDay && <LogOut size={9} style={{ color: '#ef4444' }} />}
|
||||||
{/* Details grid */}
|
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: isCheckOutDay && !isCheckInDay ? '#ef4444' : '#22c55e' }}>{dayLabel}</span>
|
||||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 10px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
</div>
|
||||||
{accommodation.check_in && (
|
)}
|
||||||
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
|
{/* Hotel header */}
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_in)}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px' }}>
|
||||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<LogIn size={8} /> {t('day.checkIn')}
|
{acc.place_image ? (
|
||||||
|
<img src={acc.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<Hotel size={16} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<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>
|
||||||
|
{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>}
|
||||||
|
{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>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* Details grid */}
|
||||||
)}
|
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||||
{accommodation.check_out && (
|
{acc.check_in && (
|
||||||
<div style={{ flex: 1, padding: '8px 10px', borderRight: accommodation.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}>
|
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_out)}</div>
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.check_in)}</div>
|
||||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||||
<LogOut size={8} /> {t('day.checkOut')}
|
<LogIn size={8} /> {t('day.checkIn')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{acc.check_out && (
|
||||||
|
<div style={{ flex: 1, padding: '8px 10px', borderRight: acc.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.check_out)}</div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||||
|
<LogOut size={8} /> {t('day.checkOut')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{acc.confirmation && (
|
||||||
|
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{acc.confirmation}</div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||||
|
<Hash size={8} /> {t('day.confirmation')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Linked booking */}
|
||||||
|
{linked && (
|
||||||
|
<div style={{ margin: '0 12px 8px', padding: '6px 10px', borderRadius: 8, background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.15)' : 'rgba(217,119,6,0.15)'}`, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ width: 6, height: 6, borderRadius: '50%', background: confirmed ? '#16a34a' : '#d97706', flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
{accommodation.confirmation && (
|
})}
|
||||||
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
|
{/* Add another hotel */}
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{accommodation.confirmation}</div>
|
{canEditDays && <button onClick={() => setShowHotelPicker(true)} style={{
|
||||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||||
<Hash size={8} /> {t('day.confirmation')}
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
</div>
|
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
</div>
|
}}>
|
||||||
)}
|
<Hotel size={10} /> {t('day.addAccommodation')}
|
||||||
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '', place_id: accommodation.place_id }); setHotelDayRange({ start: accommodation.start_day_id, end: accommodation.end_day_id }); setShowHotelPicker('edit') }}
|
</button>}
|
||||||
style={{ padding: '0 8px', background: 'none', border: 'none', borderLeft: '1px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Pencil size={10} style={{ color: 'var(--text-faint)' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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,
|
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,
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
<Hotel size={12} /> {t('day.addAccommodation')}
|
<Hotel size={12} /> {t('day.addAccommodation')}
|
||||||
</button>
|
</button> : null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||||
@@ -377,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
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"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
@@ -389,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
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"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
@@ -495,8 +563,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||||
// Reload
|
// Reload
|
||||||
accommodationsApi.list(tripId).then(d => {
|
accommodationsApi.list(tripId).then(d => {
|
||||||
setAccommodations(d.accommodations || [])
|
const all = 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))
|
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)
|
setAccommodation(acc || null)
|
||||||
})
|
})
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -42,6 +44,7 @@ interface PlaceFormModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
|
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
|
||||||
place: Place | null
|
place: Place | null
|
||||||
|
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
|
||||||
tripId: number
|
tripId: number
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
onCategoryCreated: (category: Category) => void
|
onCategoryCreated: (category: Category) => void
|
||||||
@@ -50,7 +53,7 @@ interface PlaceFormModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaceFormModal({
|
export default function PlaceFormModal({
|
||||||
isOpen, onClose, onSave, place, tripId, categories,
|
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||||
onCategoryCreated, assignmentId, dayAssignments = [],
|
onCategoryCreated, assignmentId, dayAssignments = [],
|
||||||
}: PlaceFormModalProps) {
|
}: PlaceFormModalProps) {
|
||||||
const [form, setForm] = useState(DEFAULT_FORM)
|
const [form, setForm] = useState(DEFAULT_FORM)
|
||||||
@@ -65,6 +68,9 @@ export default function PlaceFormModal({
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
const { hasMapsKey } = useAuthStore()
|
const { hasMapsKey } = useAuthStore()
|
||||||
|
const can = useCanDo()
|
||||||
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
|
const canUploadFiles = can('file_upload', tripObj)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place) {
|
if (place) {
|
||||||
@@ -81,11 +87,19 @@ export default function PlaceFormModal({
|
|||||||
transport_mode: place.transport_mode || 'walking',
|
transport_mode: place.transport_mode || 'walking',
|
||||||
website: place.website || '',
|
website: place.website || '',
|
||||||
})
|
})
|
||||||
|
} else if (prefillCoords) {
|
||||||
|
setForm({
|
||||||
|
...DEFAULT_FORM,
|
||||||
|
lat: String(prefillCoords.lat),
|
||||||
|
lng: String(prefillCoords.lng),
|
||||||
|
name: prefillCoords.name || '',
|
||||||
|
address: prefillCoords.address || '',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm(DEFAULT_FORM)
|
setForm(DEFAULT_FORM)
|
||||||
}
|
}
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
}, [place, isOpen])
|
}, [place, prefillCoords, isOpen])
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
const handleChange = (field, value) => {
|
||||||
setForm(prev => ({ ...prev, [field]: value }))
|
setForm(prev => ({ ...prev, [field]: value }))
|
||||||
@@ -95,6 +109,24 @@ export default function PlaceFormModal({
|
|||||||
if (!mapsSearch.trim()) return
|
if (!mapsSearch.trim()) return
|
||||||
setIsSearchingMaps(true)
|
setIsSearchingMaps(true)
|
||||||
try {
|
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)
|
const result = await mapsApi.search(mapsSearch, language)
|
||||||
setMapsResults(result.places || [])
|
setMapsResults(result.places || [])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -112,6 +144,9 @@ export default function PlaceFormModal({
|
|||||||
lat: result.lat || prev.lat,
|
lat: result.lat || prev.lat,
|
||||||
lng: result.lng || prev.lng,
|
lng: result.lng || prev.lng,
|
||||||
google_place_id: result.google_place_id || prev.google_place_id,
|
google_place_id: result.google_place_id || prev.google_place_id,
|
||||||
|
osm_id: result.osm_id || prev.osm_id,
|
||||||
|
website: result.website || prev.website,
|
||||||
|
phone: result.phone || prev.phone,
|
||||||
}))
|
}))
|
||||||
setMapsResults([])
|
setMapsResults([])
|
||||||
setMapsSearch('')
|
setMapsSearch('')
|
||||||
@@ -141,6 +176,7 @@ export default function PlaceFormModal({
|
|||||||
|
|
||||||
// Paste support for files/images
|
// Paste support for files/images
|
||||||
const handlePaste = (e) => {
|
const handlePaste = (e) => {
|
||||||
|
if (!canUploadFiles) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -269,6 +305,15 @@ export default function PlaceFormModal({
|
|||||||
step="any"
|
step="any"
|
||||||
value={form.lat}
|
value={form.lat}
|
||||||
onChange={e => handleChange('lat', e.target.value)}
|
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')}
|
placeholder={t('places.formLat')}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
@@ -347,7 +392,7 @@ export default function PlaceFormModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Attachments */}
|
{/* File Attachments */}
|
||||||
{true && (
|
{canUploadFiles && (
|
||||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
<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 React, { useState, useEffect, useRef, useCallback, useMemo } 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 { 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 PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -20,23 +23,21 @@ function setSessionCache(key, value) {
|
|||||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useGoogleDetails(googlePlaceId, language) {
|
function usePlaceDetails(googlePlaceId, osmId, language) {
|
||||||
const [details, setDetails] = useState(null)
|
const [details, setDetails] = useState(null)
|
||||||
const cacheKey = `gdetails_${googlePlaceId}_${language}`
|
const detailId = googlePlaceId || osmId
|
||||||
|
const cacheKey = `gdetails_${detailId}_${language}`
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!googlePlaceId) { setDetails(null); return }
|
if (!detailId) { setDetails(null); return }
|
||||||
// In-memory cache (fastest)
|
|
||||||
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
|
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
|
||||||
// sessionStorage cache (survives reload)
|
|
||||||
const cached = getSessionCache(cacheKey)
|
const cached = getSessionCache(cacheKey)
|
||||||
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
|
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
|
||||||
// Fetch from API
|
mapsApi.details(detailId, language).then(data => {
|
||||||
mapsApi.details(googlePlaceId, language).then(data => {
|
|
||||||
detailsCache.set(cacheKey, data.place)
|
detailsCache.set(cacheKey, data.place)
|
||||||
setSessionCache(cacheKey, data.place)
|
setSessionCache(cacheKey, data.place)
|
||||||
setDetails(data.place)
|
setDetails(data.place)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [googlePlaceId, language])
|
}, [detailId, language])
|
||||||
return details
|
return details
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,16 +119,19 @@ interface PlaceInspectorProps {
|
|||||||
onAssignToDay: (placeId: number, dayId: number) => void
|
onAssignToDay: (placeId: number, dayId: number) => void
|
||||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||||
files: TripFile[]
|
files: TripFile[]
|
||||||
onFileUpload: (fd: FormData) => Promise<void>
|
onFileUpload?: (fd: FormData) => Promise<void>
|
||||||
tripMembers?: TripMember[]
|
tripMembers?: TripMember[]
|
||||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||||
|
leftWidth?: number
|
||||||
|
rightWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaceInspector({
|
export default function PlaceInspector({
|
||||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||||
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
||||||
|
leftWidth = 0, rightWidth = 0,
|
||||||
}: PlaceInspectorProps) {
|
}: PlaceInspectorProps) {
|
||||||
const { t, locale, language } = useTranslation()
|
const { t, locale, language } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
@@ -138,7 +142,7 @@ export default function PlaceInspector({
|
|||||||
const [nameValue, setNameValue] = useState('')
|
const [nameValue, setNameValue] = useState('')
|
||||||
const nameInputRef = useRef(null)
|
const nameInputRef = useRef(null)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const googleDetails = useGoogleDetails(place?.google_place_id, language)
|
const googleDetails = usePlaceDetails(place?.google_place_id, place?.osm_id, language)
|
||||||
|
|
||||||
const startNameEdit = () => {
|
const startNameEdit = () => {
|
||||||
if (!onUpdatePlace) return
|
if (!onUpdatePlace) return
|
||||||
@@ -171,7 +175,7 @@ export default function PlaceInspector({
|
|||||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
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 handleFileUpload = useCallback(async (e) => {
|
||||||
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||||
@@ -198,9 +202,9 @@ export default function PlaceInspector({
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
left: '50%',
|
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: 'min(800px, calc(100vw - 32px))',
|
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
@@ -314,7 +318,7 @@ export default function PlaceInspector({
|
|||||||
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
||||||
text={<>
|
text={<>
|
||||||
{googleDetails.rating.toFixed(1)}
|
{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>}
|
{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)"
|
color="var(--text-secondary)" bg="var(--bg-hover)"
|
||||||
@@ -327,21 +331,19 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Telefon */}
|
{/* Telefon */}
|
||||||
{place.phone && (
|
{(place.phone || googleDetails?.phone) && (
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
<a href={`tel:${place.phone}`}
|
<a href={`tel:${place.phone || googleDetails.phone}`}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
||||||
<Phone size={12} /> {place.phone}
|
<Phone size={12} /> {place.phone || googleDetails.phone}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description / Summary */}
|
||||||
{(place.description || place.notes) && (
|
{(place.description || place.notes || googleDetails?.summary) && (
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
<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' }}>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
|
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
|
||||||
{place.description || place.notes}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -371,7 +373,7 @@ export default function PlaceInspector({
|
|||||||
{res.reservation_time && (
|
{res.reservation_time && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{res.reservation_time?.includes('T') && (
|
{res.reservation_time?.includes('T') && (
|
||||||
@@ -390,7 +392,21 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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
|
||||||
|
const parts: string[] = []
|
||||||
|
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
|
||||||
|
else if (meta.flight_number) parts.push(meta.flight_number)
|
||||||
|
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
|
||||||
|
if (meta.train_number) parts.push(meta.train_number)
|
||||||
|
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
|
||||||
|
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
|
||||||
|
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
|
||||||
|
if (parts.length === 0) return null
|
||||||
|
return <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 500 }}>{parts.join(' · ')}</div>
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -446,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 */}
|
{/* Files section */}
|
||||||
{(placeFiles.length > 0 || onFileUpload) && (
|
{(placeFiles.length > 0 || onFileUpload) && (
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
@@ -474,11 +582,11 @@ export default function PlaceInspector({
|
|||||||
{filesExpanded && placeFiles.length > 0 && (
|
{filesExpanded && placeFiles.length > 0 && (
|
||||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{placeFiles.map(f => (
|
{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" />}
|
{(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>
|
<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>}
|
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -502,8 +610,12 @@ export default function PlaceInspector({
|
|||||||
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||||
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
||||||
)}
|
)}
|
||||||
{place.website && (
|
{!googleDetails?.google_maps_url && place.lat && place.lng && (
|
||||||
<ActionButton onClick={() => window.open(place.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||||
|
label={<span className="hidden sm:inline">Google Maps</span>} />
|
||||||
|
)}
|
||||||
|
{(place.website || googleDetails?.website) && (
|
||||||
|
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
||||||
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
||||||
)}
|
)}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState } from 'react'
|
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||||
import DOM from 'react-dom'
|
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 PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
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'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
interface PlacesSidebarProps {
|
interface PlacesSidebarProps {
|
||||||
|
tripId: number
|
||||||
places: Place[]
|
places: Place[]
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
assignments: AssignmentsMap
|
assignments: AssignmentsMap
|
||||||
@@ -22,31 +28,103 @@ interface PlacesSidebarProps {
|
|||||||
onDeletePlace: (placeId: number) => void
|
onDeletePlace: (placeId: number) => void
|
||||||
days: Day[]
|
days: Day[]
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
|
onCategoryFilterChange?: (categoryId: string) => void
|
||||||
|
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlacesSidebar({
|
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
|
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, pushUndo,
|
||||||
}: PlacesSidebarProps) {
|
}: PlacesSidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
const ctxMenu = useContextMenu()
|
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 [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState('all')
|
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 [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||||
|
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||||
|
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||||
|
|
||||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
// 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))
|
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 (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()) &&
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
})
|
}), [places, filter, categoryFilters, search, plannedIds])
|
||||||
|
|
||||||
const isAssignedToSelectedDay = (placeId) =>
|
const isAssignedToSelectedDay = (placeId) =>
|
||||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === 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" }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
{/* Kopfbereich */}
|
{/* Kopfbereich */}
|
||||||
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||||
<button
|
{canEditPlaces && <button
|
||||||
onClick={onAddPlace}
|
onClick={onAddPlace}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
@@ -65,7 +143,36 @@ export default function PlacesSidebar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
<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 */}
|
{/* Filter-Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||||
@@ -100,21 +207,69 @@ export default function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kategoriefilter */}
|
{/* Category multi-select dropdown */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (() => {
|
||||||
<div style={{ marginTop: 6 }}>
|
const label = categoryFilters.size === 0
|
||||||
<CustomSelect
|
? t('places.allCategories')
|
||||||
value={categoryFilter}
|
: categoryFilters.size === 1
|
||||||
onChange={setCategoryFilter}
|
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
|
||||||
placeholder={t('places.allCategories')}
|
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||||
size="sm"
|
return (
|
||||||
options={[
|
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||||
{ value: '', label: t('places.allCategories') },
|
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||||
...categories.map(c => ({ value: String(c.id), label: c.name }))
|
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)',
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Anzahl */}
|
{/* Anzahl */}
|
||||||
@@ -129,9 +284,9 @@ export default function PlacesSidebar({
|
|||||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||||
</span>
|
</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')}
|
{t('places.addPlace')}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map(place => {
|
filtered.map(place => {
|
||||||
@@ -151,19 +306,19 @@ export default function PlacesSidebar({
|
|||||||
window.__dragData = { placeId: String(place.id) }
|
window.__dragData = { placeId: String(place.id) }
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isMobile && days?.length > 0) {
|
if (isMobile) {
|
||||||
setDayPickerPlace(place)
|
setDayPickerPlace(place)
|
||||||
} else {
|
} else {
|
||||||
onPlaceClick(isSelected ? null : place.id)
|
onPlaceClick(isSelected ? null : place.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={e => ctxMenu.open(e, [
|
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) },
|
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.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') },
|
(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 },
|
{ 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={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
@@ -172,6 +327,8 @@ export default function PlacesSidebar({
|
|||||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||||
borderBottom: '1px solid var(--border-faint)',
|
borderBottom: '1px solid var(--border-faint)',
|
||||||
transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
|
contentVisibility: 'auto',
|
||||||
|
containIntrinsicSize: '0 52px',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||||
@@ -216,49 +373,133 @@ export default function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
|
{dayPickerPlace && ReactDOM.createPortal(
|
||||||
<div
|
<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' }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={e => e.stopPropagation()}
|
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={{ 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: 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>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
|
||||||
{days.map((day, i) => {
|
{/* View details */}
|
||||||
return (
|
<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
|
<button
|
||||||
key={day.id}
|
onClick={() => setMobileShowDays(v => !v)}
|
||||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
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)' }}
|
||||||
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'}
|
|
||||||
>
|
>
|
||||||
<div style={{
|
<CalendarDays size={18} color="var(--text-muted)" /> {t('places.assignToDay')}
|
||||||
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
<ChevronDown size={14} style={{ marginLeft: 'auto', color: 'var(--text-faint)', transform: mobileShowDays ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
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>}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -267,4 +508,6 @@ export default function PlacesSidebar({
|
|||||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default PlacesSidebar
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
@@ -6,7 +9,7 @@ import { useToast } from '../shared/Toast'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap } from '../../types'
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||||
@@ -56,23 +59,33 @@ interface ReservationModalProps {
|
|||||||
assignments: AssignmentsMap
|
assignments: AssignmentsMap
|
||||||
selectedDayId: number | null
|
selectedDayId: number | null
|
||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onFileUpload: (fd: FormData) => Promise<void>
|
onFileUpload?: (fd: FormData) => Promise<void>
|
||||||
onFileDelete: (fileId: number) => Promise<void>
|
onFileDelete: (fileId: number) => Promise<void>
|
||||||
|
accommodations?: Accommodation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }: ReservationModalProps) {
|
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 toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: '',
|
notes: '', assignment_id: '', accommodation_id: '',
|
||||||
|
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||||
|
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||||
|
meta_check_in_time: '', meta_check_out_time: '',
|
||||||
|
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||||
})
|
})
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [uploadingFile, setUploadingFile] = useState(false)
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
const [pendingFiles, setPendingFiles] = useState([])
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||||
|
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||||
|
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||||
|
|
||||||
const assignmentOptions = useMemo(
|
const assignmentOptions = useMemo(
|
||||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||||
@@ -81,6 +94,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reservation) {
|
if (reservation) {
|
||||||
|
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||||
setForm({
|
setForm({
|
||||||
title: reservation.title || '',
|
title: reservation.title || '',
|
||||||
type: reservation.type || 'other',
|
type: reservation.type || 'other',
|
||||||
@@ -91,12 +105,28 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
confirmation_number: reservation.confirmation_number || '',
|
confirmation_number: reservation.confirmation_number || '',
|
||||||
notes: reservation.notes || '',
|
notes: reservation.notes || '',
|
||||||
assignment_id: reservation.assignment_id || '',
|
assignment_id: reservation.assignment_id || '',
|
||||||
|
accommodation_id: reservation.accommodation_id || '',
|
||||||
|
meta_airline: meta.airline || '',
|
||||||
|
meta_flight_number: meta.flight_number || '',
|
||||||
|
meta_departure_airport: meta.departure_airport || '',
|
||||||
|
meta_arrival_airport: meta.arrival_airport || '',
|
||||||
|
meta_train_number: meta.train_number || '',
|
||||||
|
meta_platform: meta.platform || '',
|
||||||
|
meta_seat: meta.seat || '',
|
||||||
|
meta_check_in_time: meta.check_in_time || '',
|
||||||
|
meta_check_out_time: meta.check_out_time || '',
|
||||||
|
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 || '' })(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: '',
|
notes: '', assignment_id: '', accommodation_id: '',
|
||||||
|
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||||
|
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||||
|
meta_check_in_time: '', meta_check_out_time: '',
|
||||||
})
|
})
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
}
|
}
|
||||||
@@ -109,10 +139,41 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
if (!form.title.trim()) return
|
if (!form.title.trim()) return
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
const saved = await onSave({
|
const metadata: Record<string, string> = {}
|
||||||
...form,
|
if (form.type === 'flight') {
|
||||||
|
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||||
|
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
|
||||||
|
} 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
|
||||||
|
} else if (form.type === 'train') {
|
||||||
|
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||||
|
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||||
|
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
location: form.location, confirmation_number: form.confirmation_number,
|
||||||
|
notes: form.notes,
|
||||||
assignment_id: form.assignment_id || null,
|
assignment_id: form.assignment_id || null,
|
||||||
})
|
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||||
|
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||||
|
}
|
||||||
|
// 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 = {
|
||||||
|
place_id: form.hotel_place_id,
|
||||||
|
start_day_id: form.hotel_start_day,
|
||||||
|
end_day_id: form.hotel_end_day,
|
||||||
|
check_in: form.meta_check_in_time || null,
|
||||||
|
check_out: form.meta_check_out_time || null,
|
||||||
|
confirmation: form.confirmation_number || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const saved = await onSave(saveData)
|
||||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
||||||
for (const file of pendingFiles) {
|
for (const file of pendingFiles) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
@@ -151,7 +212,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 = {
|
const inputStyle = {
|
||||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
@@ -190,7 +257,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assignment Picker + Date */}
|
{/* Assignment Picker + Date (hidden for hotels) */}
|
||||||
|
{form.type !== 'hotel' && (
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{assignmentOptions.length > 0 && (
|
{assignmentOptions.length > 0 && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -231,24 +299,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Start Time + End Time + Status */}
|
{/* Start Time + End Time + Status */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
{form.type !== 'hotel' && (
|
||||||
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
<>
|
||||||
<CustomTimePicker
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
||||||
onChange={t => {
|
<CustomTimePicker
|
||||||
const [d] = (form.reservation_time || '').split('T')
|
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||||
const date = d || new Date().toISOString().split('T')[0]
|
onChange={t => {
|
||||||
set('reservation_time', t ? `${date}T${t}` : date)
|
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>
|
}}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
/>
|
||||||
<label style={labelStyle}>{t('reservations.endTime')}</label>
|
</div>
|
||||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
</div>
|
<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 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
@@ -277,6 +350,112 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Type-specific fields */}
|
||||||
|
{form.type === 'flight' && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
|
||||||
|
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||||
|
placeholder="Lufthansa" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
|
||||||
|
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||||
|
placeholder="LH 123" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
|
||||||
|
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
|
||||||
|
placeholder="FRA" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
|
||||||
|
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
|
||||||
|
placeholder="NRT" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.type === 'hotel' && (
|
||||||
|
<>
|
||||||
|
{/* Hotel place + day range */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.hotel_place_id}
|
||||||
|
onChange={value => {
|
||||||
|
set('hotel_place_id', value)
|
||||||
|
const p = places.find(pl => pl.id === value)
|
||||||
|
if (p) {
|
||||||
|
if (!form.title) set('title', p.name)
|
||||||
|
if (!form.location && p.address) set('location', p.address)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('reservations.meta.pickHotel')}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '—' },
|
||||||
|
...places.map(p => ({ value: p.id, label: p.name })),
|
||||||
|
]}
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.hotel_start_day}
|
||||||
|
onChange={value => set('hotel_start_day', value)}
|
||||||
|
placeholder={t('reservations.meta.selectDay')}
|
||||||
|
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.hotel_end_day}
|
||||||
|
onChange={value => set('hotel_end_day', value)}
|
||||||
|
placeholder={t('reservations.meta.selectDay')}
|
||||||
|
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Check-in/out times */}
|
||||||
|
<div className="grid grid-cols-2 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)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.type === 'train' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
|
||||||
|
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||||
|
placeholder="ICE 123" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
|
||||||
|
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||||
|
placeholder="12" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
|
||||||
|
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||||
|
placeholder="42A" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||||
@@ -294,11 +473,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
<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>
|
<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>
|
<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={async () => {
|
||||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
// Always unlink, never delete the file
|
||||||
<X size={11} />
|
// Clear primary reservation_id if it points to this reservation
|
||||||
</button>
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{pendingFiles.map((f, i) => (
|
{pendingFiles.map((f, i) => (
|
||||||
@@ -312,14 +503,56 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
<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={{
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
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')}
|
<Paperclip size={11} />
|
||||||
</button>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,6 +572,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
|
|
||||||
function formatDate(dateStr, locale) {
|
function formatDate(dateStr, locale) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00Z')
|
||||||
return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
|
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -55,31 +57,35 @@ interface ReservationCardProps {
|
|||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onNavigateToFiles: () => void
|
onNavigateToFiles: () => void
|
||||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
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 { toggleReservationStatus } = useTripStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
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 typeInfo = getType(r.type)
|
||||||
const TypeIcon = typeInfo.Icon
|
const TypeIcon = typeInfo.Icon
|
||||||
const confirmed = r.status === 'confirmed'
|
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 linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
try { await toggleReservationStatus(tripId, r.id) }
|
try { await toggleReservationStatus(tripId, r.id) }
|
||||||
catch { toast.error(t('reservations.toast.updateError')) }
|
catch { toast.error(t('reservations.toast.updateError')) }
|
||||||
}
|
}
|
||||||
const handleDelete = async () => {
|
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')) }
|
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtDate = (str) => {
|
const fmtDate = (str) => {
|
||||||
const d = new Date(str)
|
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
}
|
}
|
||||||
const fmtTime = (str) => {
|
const fmtTime = (str) => {
|
||||||
const d = new Date(str)
|
const d = new Date(str)
|
||||||
@@ -91,28 +97,38 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
{/* Header bar */}
|
{/* 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={{ 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' }} />
|
<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' }}>
|
{canEdit ? (
|
||||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||||
</button>
|
{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)' }} />
|
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
<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 }}
|
{canEdit && (
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
<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 }}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
<Pencil size={11} />
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
</button>
|
<Pencil size={11} />
|
||||||
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
</button>
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
)}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
{canEdit && (
|
||||||
<Trash2 size={11} />
|
<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 }}
|
||||||
</button>
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
{(r.reservation_time || r.confirmation_number || r.location || linked) && (
|
{(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && (
|
||||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{/* Row 1: Date, Time, Code */}
|
{/* Row 1: Date, Time, Code */}
|
||||||
{(r.reservation_time || r.confirmation_number) && (
|
{(r.reservation_time || r.confirmation_number) && (
|
||||||
@@ -127,20 +143,58 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
<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: 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 }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{r.confirmation_number && (
|
{r.confirmation_number && (
|
||||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
<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: 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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Row 1b: Type-specific metadata */}
|
||||||
|
{(() => {
|
||||||
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
|
if (!meta || Object.keys(meta).length === 0) return null
|
||||||
|
const cells: { label: string; value: string }[] = []
|
||||||
|
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||||
|
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||||
|
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||||
|
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||||
|
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 (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)' }}>
|
||||||
|
{cells.map((c, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: i < cells.length - 1 ? '1px solid var(--border-faint)' : 'none' }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{c.label}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{c.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{/* Row 2: Location + Assignment */}
|
{/* Row 2: Location + Assignment */}
|
||||||
{(r.location || linked) && (
|
{(r.location || linked || r.accommodation_name) && (
|
||||||
<div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
<div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
||||||
{r.location && (
|
{r.location && (
|
||||||
<div>
|
<div>
|
||||||
@@ -151,6 +205,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{r.accommodation_name && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.meta.linkAccommodation')}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
<Hotel size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{linked && (
|
{linked && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
|
||||||
@@ -192,6 +255,46 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -239,6 +342,9 @@ interface ReservationsPanelProps {
|
|||||||
|
|
||||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||||
const { t, locale } = useTranslation()
|
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 [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||||
|
|
||||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||||
@@ -257,13 +363,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onAdd} style={{
|
{canEdit && (
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
<button onClick={onAdd} style={{
|
||||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
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>
|
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -279,14 +387,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
{allPending.length > 0 && (
|
{allPending.length > 0 && (
|
||||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{allConfirmed.length > 0 && (
|
{allConfirmed.length > 0 && (
|
||||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { Calendar, Camera, X, Clipboard } from 'lucide-react'
|
import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
|
||||||
import { tripsApi } from '../../api/client'
|
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 { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
@@ -20,36 +23,66 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
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({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
|
reminder_days: 0 as number,
|
||||||
})
|
})
|
||||||
|
const [customReminder, setCustomReminder] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [coverPreview, setCoverPreview] = useState(null)
|
const [coverPreview, setCoverPreview] = useState(null)
|
||||||
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
||||||
const [uploadingCover, setUploadingCover] = useState(false)
|
const [uploadingCover, setUploadingCover] = useState(false)
|
||||||
|
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
|
||||||
|
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
||||||
|
const [memberSelectValue, setMemberSelectValue] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trip) {
|
if (trip) {
|
||||||
|
const rd = trip.reminder_days ?? 3
|
||||||
setFormData({
|
setFormData({
|
||||||
title: trip.title || '',
|
title: trip.title || '',
|
||||||
description: trip.description || '',
|
description: trip.description || '',
|
||||||
start_date: trip.start_date || '',
|
start_date: trip.start_date || '',
|
||||||
end_date: trip.end_date || '',
|
end_date: trip.end_date || '',
|
||||||
|
reminder_days: rd,
|
||||||
})
|
})
|
||||||
|
setCustomReminder(![0, 1, 3, 9].includes(rd))
|
||||||
setCoverPreview(trip.cover_image || null)
|
setCoverPreview(trip.cover_image || null)
|
||||||
} else {
|
} else {
|
||||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0 })
|
||||||
|
setCustomReminder(false)
|
||||||
setCoverPreview(null)
|
setCoverPreview(null)
|
||||||
}
|
}
|
||||||
setPendingCoverFile(null)
|
setPendingCoverFile(null)
|
||||||
|
setSelectedMembers([])
|
||||||
setError('')
|
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])
|
}, [trip, isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trip && isOpen) {
|
||||||
|
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
|
||||||
|
}
|
||||||
|
}, [tripRemindersEnabled])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -64,7 +97,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
description: formData.description.trim() || null,
|
description: formData.description.trim() || null,
|
||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
end_date: formData.end_date || null,
|
end_date: formData.end_date || null,
|
||||||
|
reminder_days: formData.reminder_days,
|
||||||
})
|
})
|
||||||
|
// 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
|
// Upload pending cover for newly created trips
|
||||||
if (pendingCoverFile && result?.trip?.id) {
|
if (pendingCoverFile && result?.trip?.id) {
|
||||||
try {
|
try {
|
||||||
@@ -135,6 +178,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
|
|
||||||
// Paste support for cover image
|
// Paste support for cover image
|
||||||
const handlePaste = (e) => {
|
const handlePaste = (e) => {
|
||||||
|
if (!canUploadCover) return
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -153,10 +197,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
if (!prev.end_date || prev.end_date < value) {
|
if (!prev.end_date || prev.end_date < value) {
|
||||||
next.end_date = value
|
next.end_date = value
|
||||||
} else if (prev.start_date) {
|
} else if (prev.start_date) {
|
||||||
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
|
||||||
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
|
||||||
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
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)
|
newEnd.setDate(newEnd.getDate() + duration)
|
||||||
next.end_date = newEnd.toISOString().split('T')[0]
|
next.end_date = newEnd.toISOString().split('T')[0]
|
||||||
}
|
}
|
||||||
@@ -192,8 +236,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>
|
<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 */}
|
{/* Cover image — gated by trip_cover_upload permission */}
|
||||||
<div>
|
{canUploadCover && <div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
<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} />
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
||||||
{coverPreview ? (
|
{coverPreview ? (
|
||||||
@@ -212,26 +256,29 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
<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' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
|
||||||
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
<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)}
|
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
|
||||||
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||||
className={`${inputCls} resize-none`} />
|
className={`${inputCls} resize-none`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -250,11 +297,99 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!formData.start_date && !formData.end_date && (
|
{/* Reminder — only visible to owner (or when creating) */}
|
||||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
|
||||||
{t('dashboard.noDateHint')}
|
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
|
||||||
</p>
|
<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>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { tripsApi, authApi } from '../../api/client'
|
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
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 { useTranslation } from '../../i18n'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
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 {
|
interface TripMembersModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -49,6 +174,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const { t } = useTranslation()
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && tripId) {
|
if (isOpen && tripId) {
|
||||||
@@ -123,8 +252,12 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
] : []
|
] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
|
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<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 */}
|
{/* Trip name */}
|
||||||
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Add member dropdown */}
|
{/* Add member dropdown */}
|
||||||
<div>
|
{canManageMembers && <div>
|
||||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
{t('members.inviteUser')}
|
{t('members.inviteUser')}
|
||||||
</label>
|
</label>
|
||||||
@@ -166,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Members list */}
|
{/* Members list */}
|
||||||
<div>
|
<div>
|
||||||
@@ -190,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{allMembers.map(member => {
|
{allMembers.map(member => {
|
||||||
const isSelf = member.id === user?.id
|
const isSelf = member.id === user?.id
|
||||||
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
|
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||||
return (
|
return (
|
||||||
<div key={member.id} style={{
|
<div key={member.id} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
@@ -228,6 +361,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default function VacayCalendar() {
|
|||||||
}, [entries])
|
}, [entries])
|
||||||
|
|
||||||
const blockWeekends = plan?.block_weekends !== false
|
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 companyHolidaysEnabled = plan?.company_holidays_enabled !== false
|
||||||
|
|
||||||
const handleCellClick = useCallback(async (dateStr) => {
|
const handleCellClick = useCallback(async (dateStr) => {
|
||||||
@@ -35,7 +36,7 @@ export default function VacayCalendar() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (holidays[dateStr]) return
|
if (holidays[dateStr]) return
|
||||||
if (blockWeekends && isWeekend(dateStr)) return
|
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
|
||||||
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||||
await toggleEntry(dateStr, selectedUserId || undefined)
|
await toggleEntry(dateStr, selectedUserId || undefined)
|
||||||
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
||||||
@@ -57,6 +58,7 @@ export default function VacayCalendar() {
|
|||||||
onCellClick={handleCellClick}
|
onCellClick={handleCellClick}
|
||||||
companyMode={companyMode}
|
companyMode={companyMode}
|
||||||
blockWeekends={blockWeekends}
|
blockWeekends={blockWeekends}
|
||||||
|
weekendDays={weekendDays}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { isWeekend } from './holidays'
|
import { isWeekend } from './holidays'
|
||||||
import type { HolidaysMap, VacayEntry } from '../../types'
|
import type { HolidaysMap, VacayEntry } from '../../types'
|
||||||
|
|
||||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
|
||||||
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']
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
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 {
|
interface VacayMonthCardProps {
|
||||||
year: number
|
year: number
|
||||||
@@ -18,16 +22,18 @@ interface VacayMonthCardProps {
|
|||||||
onCellClick: (date: string) => void
|
onCellClick: (date: string) => void
|
||||||
companyMode: boolean
|
companyMode: boolean
|
||||||
blockWeekends: boolean
|
blockWeekends: boolean
|
||||||
|
weekendDays?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VacayMonthCard({
|
export default function VacayMonthCard({
|
||||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||||
onCellClick, companyMode, blockWeekends
|
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
|
||||||
}: VacayMonthCardProps) {
|
}: VacayMonthCardProps) {
|
||||||
const { language } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
|
||||||
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
|
|
||||||
|
|
||||||
|
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 weeks = useMemo(() => {
|
||||||
const firstDay = new Date(year, month, 1)
|
const firstDay = new Date(year, month, 1)
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
@@ -47,7 +53,7 @@ export default function VacayMonthCard({
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<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)' }}>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<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 }} />
|
if (day === null) return <div key={di} style={{ height: 28 }} />
|
||||||
|
|
||||||
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
|
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 holiday = holidays[dateStr]
|
||||||
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
||||||
const dayEntries = entryMap[dateStr] || []
|
const dayEntries = entryMap[dateStr] || []
|
||||||
@@ -74,6 +81,7 @@ export default function VacayMonthCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={di}
|
key={di}
|
||||||
|
title={holiday ? (holiday.label ? `${holiday.label}: ${holiday.localName}` : holiday.localName) : undefined}
|
||||||
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
||||||
style={{
|
style={{
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -86,7 +94,7 @@ export default function VacayMonthCard({
|
|||||||
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
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)' }} />}
|
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
||||||
|
|
||||||
{dayEntries.length === 1 && (
|
{dayEntries.length === 1 && (
|
||||||
@@ -115,7 +123,7 @@ export default function VacayMonthCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="relative z-[1] text-[11px] font-medium" style={{
|
<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,
|
fontWeight: dayEntries.length > 0 ? 700 : 500,
|
||||||
}}>
|
}}>
|
||||||
{day}
|
{day}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { useVacayStore } from '../../store/vacayStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
|
import type { VacayHolidayCalendar } from '../../types'
|
||||||
|
|
||||||
interface VacaySettingsProps {
|
interface VacaySettingsProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -13,10 +14,9 @@ interface VacaySettingsProps {
|
|||||||
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
|
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
|
||||||
const [countries, setCountries] = useState([])
|
const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
|
||||||
const [regions, setRegions] = useState([])
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [loadingRegions, setLoadingRegions] = useState(false)
|
|
||||||
|
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/addons/vacay/holidays/countries').then(r => {
|
apiClient.get('/addons/vacay/holidays/countries').then(r => {
|
||||||
let displayNames
|
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 => ({
|
const list = r.data.map(c => ({
|
||||||
value: c.countryCode,
|
value: c.countryCode,
|
||||||
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
|
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
|
||||||
@@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [language])
|
}, [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
|
if (!plan) return null
|
||||||
|
|
||||||
const toggle = (key) => updatePlan({ [key]: !plan[key] })
|
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
|
||||||
|
|
||||||
const handleCountryChange = (countryCode) => {
|
|
||||||
updatePlan({ holidays_region: countryCode })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegionChange = (regionCode) => {
|
|
||||||
updatePlan({ holidays_region: regionCode })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -97,6 +49,42 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
onChange={() => toggle('block_weekends')}
|
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 */}
|
{/* Carry-over */}
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
icon={ArrowRightLeft}
|
icon={ArrowRightLeft}
|
||||||
@@ -136,21 +124,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
/>
|
/>
|
||||||
{plan.holidays_enabled && (
|
{plan.holidays_enabled && (
|
||||||
<div className="ml-7 mt-2 space-y-2">
|
<div className="ml-7 mt-2 space-y-2">
|
||||||
<CustomSelect
|
{(plan.holiday_calendars ?? []).length === 0 && (
|
||||||
value={selectedCountry}
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('vacay.noCalendars')}</p>
|
||||||
onChange={handleCountryChange}
|
)}
|
||||||
options={countries}
|
{(plan.holiday_calendars ?? []).map(cal => (
|
||||||
placeholder={t('vacay.selectCountry')}
|
<CalendarRow
|
||||||
searchable
|
key={cal.id}
|
||||||
/>
|
cal={cal}
|
||||||
{regions.length > 0 && (
|
countries={countries}
|
||||||
<CustomSelect
|
language={language}
|
||||||
value={selectedRegion}
|
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
|
||||||
onChange={handleRegionChange}
|
onDelete={() => deleteHolidayCalendar(cal.id)}
|
||||||
options={regions}
|
|
||||||
placeholder={t('vacay.selectRegion')}
|
|
||||||
searchable
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -197,11 +199,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SettingToggleProps {
|
interface SettingToggleProps {
|
||||||
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
|
icon: LucideIcon
|
||||||
label: string
|
label: string
|
||||||
hint: string
|
hint: string
|
||||||
value: boolean
|
value: boolean
|
||||||
onChange: (value: boolean) => void
|
onChange: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
||||||
@@ -223,3 +225,202 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
|
|||||||
</div>
|
</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
|
return holidays
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWeekend(dateStr: string): boolean {
|
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00Z')
|
||||||
const day = d.getDay()
|
return weekendDays.includes(d.getUTCDay())
|
||||||
return day === 0 || day === 6
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWeekday(dateStr: string): string {
|
export function getWeekday(dateStr: string): string {
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00Z')
|
||||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
|
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getUTCDay()]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWeekdayFull(dateStr: string): string {
|
export function getWeekdayFull(dateStr: string): string {
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00Z')
|
||||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
|
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getUTCDay()]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function daysInMonth(year: number, month: number): number {
|
export function daysInMonth(year: number, month: number): number {
|
||||||
return new Date(year, month, 0).getDate()
|
return new Date(year, month, 0).getDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string): string {
|
export function formatDate(dateStr: string, locale?: string): string {
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00Z')
|
||||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export { BUNDESLAENDER }
|
export { BUNDESLAENDER }
|
||||||
|
|||||||
@@ -11,17 +11,19 @@ interface CustomDatePickerProps {
|
|||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
style?: React.CSSProperties
|
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 { locale, t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const dropRef = useRef<HTMLDivElement>(null)
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const parsed = value ? new Date(value + 'T00:00:00') : null
|
const parsed = value ? new Date(value + 'T00:00:00Z') : null
|
||||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
|
||||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -34,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
|
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
|
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 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 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 selectDay = (day: number) => {
|
||||||
const y = String(viewYear)
|
const y = String(viewYear)
|
||||||
@@ -55,26 +57,61 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
|||||||
setOpen(false)
|
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 today = new Date()
|
||||||
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
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 (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
<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={{
|
style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: compact ? 4 : 8,
|
||||||
padding: '8px 14px', borderRadius: 10,
|
padding: compact ? '4px 6px' : '8px 14px', borderRadius: compact ? 4 : 10,
|
||||||
border: '1px solid var(--border-primary)',
|
border: borderless ? 'none' : '1px solid var(--border-primary)',
|
||||||
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
background: borderless ? 'transparent' : 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||||
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
||||||
transition: 'border-color 0.15s',
|
transition: 'border-color 0.15s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
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>
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{open && ReactDOM.createPortal(
|
{open && ReactDOM.createPortal(
|
||||||
<div ref={dropRef} style={{
|
<div ref={dropRef} style={{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface CustomSelectProps {
|
|||||||
searchable?: boolean
|
searchable?: boolean
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
size?: 'sm' | 'md'
|
size?: 'sm' | 'md'
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomSelect({
|
export default function CustomSelect({
|
||||||
@@ -29,6 +30,7 @@ export default function CustomSelect({
|
|||||||
searchable = false,
|
searchable = false,
|
||||||
style = {},
|
style = {},
|
||||||
size = 'md',
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
}: CustomSelectProps) {
|
}: CustomSelectProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -83,17 +85,19 @@ export default function CustomSelect({
|
|||||||
{/* Trigger */}
|
{/* Trigger */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setOpen(o => !o); setSearch('') }}
|
disabled={disabled}
|
||||||
|
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
||||||
border: '1px solid var(--border-primary)',
|
border: '1px solid var(--border-primary)',
|
||||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
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,
|
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)' }}
|
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
||||||
@@ -107,9 +111,15 @@ export default function CustomSelect({
|
|||||||
{open && ReactDOM.createPortal(
|
{open && ReactDOM.createPortal(
|
||||||
<div ref={dropRef} style={{
|
<div ref={dropRef} style={{
|
||||||
position: 'fixed',
|
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 })(),
|
const r = ref.current?.getBoundingClientRect()
|
||||||
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
|
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,
|
zIndex: 99999,
|
||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
backdropFilter: 'blur(24px) saturate(180%)',
|
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 handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const raw = e.target.value
|
const raw = e.target.value
|
||||||
onChange(raw)
|
onChange(raw)
|
||||||
|
if (is12h) return // let handleBlur parse 12h formats
|
||||||
const clean = raw.replace(/[^0-9:]/g, '')
|
const clean = raw.replace(/[^0-9:]/g, '')
|
||||||
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
||||||
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
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 = () => {
|
const handleBlur = () => {
|
||||||
if (!value) return
|
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)) {
|
if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||||
const [hh, mm] = clean.split(':')
|
const [hh, mm] = clean.split(':')
|
||||||
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const sizeClasses: Record<string, string> = {
|
|||||||
lg: 'max-w-lg',
|
lg: 'max-w-lg',
|
||||||
xl: 'max-w-2xl',
|
xl: 'max-w-2xl',
|
||||||
'2xl': 'max-w-4xl',
|
'2xl': 'max-w-4xl',
|
||||||
|
'3xl': 'max-w-5xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { mapsApi } from '../../api/client'
|
|
||||||
import { getCategoryIcon } from './categoryIcons'
|
import { getCategoryIcon } from './categoryIcons'
|
||||||
|
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
||||||
import type { Place } from '../../types'
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
@@ -9,34 +9,57 @@ interface Category {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PlaceAvatarProps {
|
interface PlaceAvatarProps {
|
||||||
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id'>
|
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id' | 'osm_id' | 'lat' | 'lng'>
|
||||||
size?: number
|
size?: number
|
||||||
category?: Category | null
|
category?: Category | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const googlePhotoCache = new Map<string, string>()
|
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||||
|
|
||||||
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
|
||||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||||
if (!place.google_place_id) { setPhotoSrc(null); return }
|
const photoId = place.google_place_id || place.osm_id
|
||||||
|
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
||||||
|
|
||||||
if (googlePhotoCache.has(place.google_place_id)) {
|
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||||
setPhotoSrc(googlePhotoCache.get(place.google_place_id)!)
|
|
||||||
|
const cached = getCached(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
|
||||||
|
if (!cached.thumbDataUrl && cached.photoUrl) {
|
||||||
|
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mapsApi.placePhoto(place.google_place_id)
|
if (isLoading(cacheKey)) {
|
||||||
.then((data: { photoUrl?: string }) => {
|
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||||
if (data.photoUrl) {
|
}
|
||||||
googlePhotoCache.set(place.google_place_id!, data.photoUrl)
|
|
||||||
setPhotoSrc(data.photoUrl)
|
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
|
||||||
}
|
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||||
})
|
)
|
||||||
.catch(() => {})
|
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||||
}, [place.id, place.image_url, place.google_place_id])
|
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||||
|
|
||||||
const bgColor = category?.color || '#6366f1'
|
const bgColor = category?.color || '#6366f1'
|
||||||
const IconComp = getCategoryIcon(category?.icon)
|
const IconComp = getCategoryIcon(category?.icon)
|
||||||
@@ -53,10 +76,11 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
|||||||
|
|
||||||
if (photoSrc) {
|
if (photoSrc) {
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div ref={ref} style={containerStyle}>
|
||||||
<img
|
<img
|
||||||
src={photoSrc}
|
src={photoSrc}
|
||||||
alt={place.name}
|
alt={place.name}
|
||||||
|
decoding="async"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => setPhotoSrc(null)}
|
||||||
/>
|
/>
|
||||||
@@ -65,8 +89,8 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div ref={ref} style={containerStyle}>
|
||||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ declare global {
|
|||||||
|
|
||||||
let toastIdCounter = 0
|
let toastIdCounter = 0
|
||||||
|
|
||||||
|
const ICON_COLORS: Record<ToastType, string> = {
|
||||||
|
success: '#22c55e',
|
||||||
|
error: '#ef4444',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
info: '#6366f1',
|
||||||
|
}
|
||||||
|
|
||||||
export function ToastContainer() {
|
export function ToastContainer() {
|
||||||
const [toasts, setToasts] = useState<Toast[]>([])
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
|
||||||
@@ -31,7 +38,7 @@ export function ToastContainer() {
|
|||||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
}, 300)
|
}, 400)
|
||||||
}, duration)
|
}, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +49,7 @@ export function ToastContainer() {
|
|||||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
}, 300)
|
}, 400)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,42 +58,83 @@ export function ToastContainer() {
|
|||||||
}, [addToast])
|
}, [addToast])
|
||||||
|
|
||||||
const icons: Record<ToastType, React.ReactNode> = {
|
const icons: Record<ToastType, React.ReactNode> = {
|
||||||
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
|
success: <CheckCircle size={18} style={{ color: ICON_COLORS.success, flexShrink: 0 }} />,
|
||||||
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
|
error: <XCircle size={18} style={{ color: ICON_COLORS.error, flexShrink: 0 }} />,
|
||||||
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
|
warning: <AlertCircle size={18} style={{ color: ICON_COLORS.warning, flexShrink: 0 }} />,
|
||||||
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
|
info: <Info size={18} style={{ color: ICON_COLORS.info, flexShrink: 0 }} />,
|
||||||
}
|
|
||||||
|
|
||||||
const bgColors: Record<ToastType, string> = {
|
|
||||||
success: 'bg-white border-l-4 border-emerald-500',
|
|
||||||
error: 'bg-white border-l-4 border-red-500',
|
|
||||||
warning: 'bg-white border-l-4 border-amber-500',
|
|
||||||
info: 'bg-white border-l-4 border-blue-500',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
<>
|
||||||
{toasts.map(toast => (
|
<style>{`
|
||||||
<div
|
@keyframes toast-in {
|
||||||
key={toast.id}
|
from { opacity: 0; transform: translateY(16px) scale(0.95); }
|
||||||
className={`
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
${bgColors[toast.type] || bgColors.info}
|
}
|
||||||
${toast.removing ? 'toast-exit' : 'toast-enter'}
|
@keyframes toast-out {
|
||||||
flex items-start gap-3 p-4 rounded-lg shadow-lg pointer-events-auto
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
min-w-0
|
to { opacity: 0; transform: translateY(8px) scale(0.95); }
|
||||||
`}
|
}
|
||||||
>
|
.nomad-toast {
|
||||||
{icons[toast.type] || icons.info}
|
background: rgba(255, 255, 255, 0.65);
|
||||||
<p className="text-sm text-slate-700 flex-1 leading-relaxed">{toast.message}</p>
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
<button
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 0.5px 0 rgba(255,255,255,0.5);
|
||||||
onClick={() => removeToast(toast.id)}
|
}
|
||||||
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
|
.nomad-toast span { color: rgba(0, 0, 0, 0.8) !important; }
|
||||||
|
.dark .nomad-toast {
|
||||||
|
background: rgba(30, 30, 40, 0.55);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), inset 0 0.5px 0 rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.dark .nomad-toast span { color: rgba(255, 255, 255, 0.9) !important; }
|
||||||
|
.nomad-toast-close { color: rgba(0, 0, 0, 0.4); }
|
||||||
|
.dark .nomad-toast-close { color: rgba(255, 255, 255, 0.4); }
|
||||||
|
`}</style>
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
||||||
|
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
||||||
|
}}>
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className="nomad-toast"
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 14,
|
||||||
|
backdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
animation: toast.removing ? 'toast-out 0.35s ease forwards' : 'toast-in 0.35s cubic-bezier(0.16,1,0.3,1) forwards',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
{icons[toast.type] || icons.info}
|
||||||
</button>
|
<span style={{
|
||||||
</div>
|
flex: 1, fontSize: 13, fontWeight: 500, color: 'rgba(255, 255, 255, 0.9)',
|
||||||
))}
|
lineHeight: 1.4,
|
||||||
</div>
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
}}>
|
||||||
|
{toast.message}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
className="nomad-toast-close"
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
display: 'flex', padding: 2,
|
||||||
|
flexShrink: 0, borderRadius: 6, transition: 'opacity 0.15s',
|
||||||
|
opacity: 0.35,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '0.35'}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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 [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||||
const routeAbortRef = useRef<AbortController | null>(null)
|
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) => {
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
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)
|
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
||||||
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
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([])
|
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||||
else if (!(err instanceof Error)) 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(() => {
|
useEffect(() => {
|
||||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
updateRouteForDay(selectedDayId)
|
updateRouteForDay(selectedDayId)
|
||||||
}, [selectedDayId, tripStore.assignments])
|
}, [selectedDayId, selectedDayAssignments])
|
||||||
|
|
||||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
import React, { createContext, useContext, useMemo, ReactNode } from 'react'
|
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import de from './translations/de'
|
import de from './translations/de'
|
||||||
import en from './translations/en'
|
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 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: 'it', label: 'Italiano' },
|
||||||
|
{ value: 'ar', label: 'العربية' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, 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', 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', 'nl', 'ar', 'cs', 'pl'].includes(language) ? language : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRtlLanguage(language: string): boolean {
|
||||||
|
return RTL_LANGUAGES.has(language)
|
||||||
|
}
|
||||||
|
|
||||||
interface TranslationContextValue {
|
interface TranslationContextValue {
|
||||||
t: (key: string, params?: Record<string, string | number>) => string
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
@@ -13,21 +55,26 @@ interface TranslationContextValue {
|
|||||||
locale: string
|
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 {
|
interface TranslationProviderProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
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 value = useMemo((): TranslationContextValue => {
|
||||||
const strings = translations[language] || translations.de
|
const strings = translations[language] || translations.en
|
||||||
const fallback = translations.de
|
const fallback = translations.en
|
||||||
|
|
||||||
function t(key: string, params?: Record<string, string | number>): string {
|
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) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||||
@@ -36,7 +83,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
|||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
|
return { t, language, locale: getLocaleForLanguage(language) }
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
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'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const en: Record<string, string> = {
|
const en: Record<string, string | { name: string; category: string }[]> = {
|
||||||
// Common
|
// Common
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
@@ -6,6 +6,7 @@ const en: Record<string, string> = {
|
|||||||
'common.edit': 'Edit',
|
'common.edit': 'Edit',
|
||||||
'common.add': 'Add',
|
'common.add': 'Add',
|
||||||
'common.loading': 'Loading...',
|
'common.loading': 'Loading...',
|
||||||
|
'common.import': 'Import',
|
||||||
'common.error': 'Error',
|
'common.error': 'Error',
|
||||||
'common.back': 'Back',
|
'common.back': 'Back',
|
||||||
'common.all': 'All',
|
'common.all': 'All',
|
||||||
@@ -25,6 +26,14 @@ const en: Record<string, string> = {
|
|||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
'common.saving': 'Saving...',
|
'common.saving': 'Saving...',
|
||||||
|
'common.saved': 'Saved',
|
||||||
|
'trips.reminder': 'Reminder',
|
||||||
|
'trips.reminderNone': 'None',
|
||||||
|
'trips.reminderDay': 'day',
|
||||||
|
'trips.reminderDays': 'days',
|
||||||
|
'trips.reminderCustom': 'Custom',
|
||||||
|
'trips.reminderDaysBefore': 'days before departure',
|
||||||
|
'trips.reminderDisabledHint': 'Trip reminders are disabled. Enable them in Admin > Settings > Notifications.',
|
||||||
'common.update': 'Update',
|
'common.update': 'Update',
|
||||||
'common.change': 'Change',
|
'common.change': 'Change',
|
||||||
'common.uploading': 'Uploading…',
|
'common.uploading': 'Uploading…',
|
||||||
@@ -51,9 +60,18 @@ const en: Record<string, string> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} active trips',
|
'dashboard.subtitle.activeMany': '{count} active trips',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
||||||
'dashboard.newTrip': 'New Trip',
|
'dashboard.newTrip': 'New Trip',
|
||||||
|
'dashboard.gridView': 'Grid view',
|
||||||
|
'dashboard.listView': 'List view',
|
||||||
'dashboard.currency': 'Currency',
|
'dashboard.currency': 'Currency',
|
||||||
'dashboard.timezone': 'Timezones',
|
'dashboard.timezone': 'Timezones',
|
||||||
'dashboard.localTime': 'Local',
|
'dashboard.localTime': 'Local',
|
||||||
|
'dashboard.timezoneCustomTitle': 'Custom Timezone',
|
||||||
|
'dashboard.timezoneCustomLabelPlaceholder': 'Label (optional)',
|
||||||
|
'dashboard.timezoneCustomTzPlaceholder': 'e.g. America/New_York',
|
||||||
|
'dashboard.timezoneCustomAdd': 'Add',
|
||||||
|
'dashboard.timezoneCustomErrorEmpty': 'Enter a timezone identifier',
|
||||||
|
'dashboard.timezoneCustomErrorInvalid': 'Invalid timezone. Use format like Europe/Berlin',
|
||||||
|
'dashboard.timezoneCustomErrorDuplicate': 'Already added',
|
||||||
'dashboard.emptyTitle': 'No trips yet',
|
'dashboard.emptyTitle': 'No trips yet',
|
||||||
'dashboard.emptyText': 'Create your first trip and start planning!',
|
'dashboard.emptyText': 'Create your first trip and start planning!',
|
||||||
'dashboard.emptyButton': 'Create First Trip',
|
'dashboard.emptyButton': 'Create First Trip',
|
||||||
@@ -62,7 +80,10 @@ const en: Record<string, string> = {
|
|||||||
'dashboard.sharedBy': 'Shared by {name}',
|
'dashboard.sharedBy': 'Shared by {name}',
|
||||||
'dashboard.days': 'Days',
|
'dashboard.days': 'Days',
|
||||||
'dashboard.places': 'Places',
|
'dashboard.places': 'Places',
|
||||||
|
'dashboard.members': 'Buddies',
|
||||||
'dashboard.archive': 'Archive',
|
'dashboard.archive': 'Archive',
|
||||||
|
'dashboard.copyTrip': 'Copy',
|
||||||
|
'dashboard.copySuffix': 'copy',
|
||||||
'dashboard.restore': 'Restore',
|
'dashboard.restore': 'Restore',
|
||||||
'dashboard.archived': 'Archived',
|
'dashboard.archived': 'Archived',
|
||||||
'dashboard.status.ongoing': 'Ongoing',
|
'dashboard.status.ongoing': 'Ongoing',
|
||||||
@@ -81,6 +102,8 @@ const en: Record<string, string> = {
|
|||||||
'dashboard.toast.archiveError': 'Failed to archive trip',
|
'dashboard.toast.archiveError': 'Failed to archive trip',
|
||||||
'dashboard.toast.restored': 'Trip restored',
|
'dashboard.toast.restored': 'Trip restored',
|
||||||
'dashboard.toast.restoreError': 'Failed to restore trip',
|
'dashboard.toast.restoreError': 'Failed to restore trip',
|
||||||
|
'dashboard.toast.copied': 'Trip copied!',
|
||||||
|
'dashboard.toast.copyError': 'Failed to copy trip',
|
||||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
||||||
'dashboard.editTrip': 'Edit Trip',
|
'dashboard.editTrip': 'Edit Trip',
|
||||||
'dashboard.createTrip': 'Create New Trip',
|
'dashboard.createTrip': 'Create New Trip',
|
||||||
@@ -92,7 +115,9 @@ const en: Record<string, string> = {
|
|||||||
'dashboard.endDate': 'End Date',
|
'dashboard.endDate': 'End Date',
|
||||||
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
|
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
|
||||||
'dashboard.coverImage': 'Cover Image',
|
'dashboard.coverImage': 'Cover Image',
|
||||||
'dashboard.addCoverImage': 'Add cover image',
|
'dashboard.addCoverImage': 'Add cover image (or drag & drop)',
|
||||||
|
'dashboard.addMembers': 'Travel buddies',
|
||||||
|
'dashboard.addMember': 'Add member',
|
||||||
'dashboard.coverSaved': 'Cover image saved',
|
'dashboard.coverSaved': 'Cover image saved',
|
||||||
'dashboard.coverUploadError': 'Failed to upload',
|
'dashboard.coverUploadError': 'Failed to upload',
|
||||||
'dashboard.coverRemoveError': 'Failed to remove',
|
'dashboard.coverRemoveError': 'Failed to remove',
|
||||||
@@ -128,9 +153,96 @@ const en: Record<string, string> = {
|
|||||||
'settings.temperature': 'Temperature Unit',
|
'settings.temperature': 'Temperature Unit',
|
||||||
'settings.timeFormat': 'Time Format',
|
'settings.timeFormat': 'Time Format',
|
||||||
'settings.routeCalculation': 'Route Calculation',
|
'settings.routeCalculation': 'Route Calculation',
|
||||||
|
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||||
|
'settings.notifications': 'Notifications',
|
||||||
|
'settings.notifyTripInvite': 'Trip invitations',
|
||||||
|
'settings.notifyBookingChange': 'Booking changes',
|
||||||
|
'settings.notifyTripReminder': 'Trip reminders',
|
||||||
|
'settings.notifyVacayInvite': 'Vacay fusion invitations',
|
||||||
|
'settings.notifyPhotosShared': 'Shared photos (Immich)',
|
||||||
|
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||||
|
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||||
|
'settings.notifyWebhook': 'Webhook notifications',
|
||||||
|
'admin.notifications.title': 'Notifications',
|
||||||
|
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||||
|
'admin.notifications.none': 'Disabled',
|
||||||
|
'admin.notifications.email': 'Email (SMTP)',
|
||||||
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.events': 'Notification Events',
|
||||||
|
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
|
||||||
|
'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
|
||||||
|
'admin.notifications.save': 'Save notification settings',
|
||||||
|
'admin.notifications.saved': 'Notification settings saved',
|
||||||
|
'admin.notifications.testWebhook': 'Send test webhook',
|
||||||
|
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
||||||
|
'admin.notifications.testWebhookFailed': 'Test webhook failed',
|
||||||
|
'admin.smtp.title': 'Email & Notifications',
|
||||||
|
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||||
|
'admin.smtp.testButton': 'Send test email',
|
||||||
|
'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
|
||||||
|
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||||
|
'admin.smtp.testFailed': 'Test email failed',
|
||||||
|
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
||||||
|
'settings.notificationsActive': 'Active channel',
|
||||||
|
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
|
||||||
|
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
||||||
|
'share.linkTitle': 'Public Link',
|
||||||
|
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
||||||
|
'share.createLink': 'Create link',
|
||||||
|
'share.deleteLink': 'Delete link',
|
||||||
|
'share.createError': 'Could not create link',
|
||||||
|
'common.copy': 'Copy',
|
||||||
|
'common.copied': 'Copied',
|
||||||
|
'share.permMap': 'Map & Plan',
|
||||||
|
'share.permBookings': 'Bookings',
|
||||||
|
'share.permPacking': 'Packing',
|
||||||
|
'shared.expired': 'Link expired or invalid',
|
||||||
|
'shared.expiredHint': 'This shared trip link is no longer active.',
|
||||||
|
'shared.readOnly': 'Read-only shared view',
|
||||||
|
'shared.tabPlan': 'Plan',
|
||||||
|
'shared.tabBookings': 'Bookings',
|
||||||
|
'shared.tabPacking': 'Packing',
|
||||||
|
'shared.tabBudget': 'Budget',
|
||||||
|
'shared.tabChat': 'Chat',
|
||||||
|
'shared.days': 'days',
|
||||||
|
'shared.places': 'places',
|
||||||
|
'shared.other': 'Other',
|
||||||
|
'shared.totalBudget': 'Total Budget',
|
||||||
|
'shared.messages': 'messages',
|
||||||
|
'shared.sharedVia': 'Shared via',
|
||||||
|
'shared.confirmed': 'Confirmed',
|
||||||
|
'shared.pending': 'Pending',
|
||||||
|
'share.permBudget': 'Budget',
|
||||||
|
'share.permCollab': 'Chat',
|
||||||
'settings.on': 'On',
|
'settings.on': 'On',
|
||||||
'settings.off': 'Off',
|
'settings.off': 'Off',
|
||||||
|
'settings.mcp.title': 'MCP Configuration',
|
||||||
|
'settings.mcp.endpoint': 'MCP Endpoint',
|
||||||
|
'settings.mcp.clientConfig': 'Client Configuration',
|
||||||
|
'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).',
|
||||||
|
'settings.mcp.copy': 'Copy',
|
||||||
|
'settings.mcp.copied': 'Copied!',
|
||||||
|
'settings.mcp.apiTokens': 'API Tokens',
|
||||||
|
'settings.mcp.createToken': 'Create New Token',
|
||||||
|
'settings.mcp.noTokens': 'No tokens yet. Create one to connect MCP clients.',
|
||||||
|
'settings.mcp.tokenCreatedAt': 'Created',
|
||||||
|
'settings.mcp.tokenUsedAt': 'Used',
|
||||||
|
'settings.mcp.deleteTokenTitle': 'Delete Token',
|
||||||
|
'settings.mcp.deleteTokenMessage': 'This token will stop working immediately. Any MCP client using it will lose access.',
|
||||||
|
'settings.mcp.modal.createTitle': 'Create API Token',
|
||||||
|
'settings.mcp.modal.tokenName': 'Token Name',
|
||||||
|
'settings.mcp.modal.tokenNamePlaceholder': 'e.g. Claude Desktop, Work laptop',
|
||||||
|
'settings.mcp.modal.creating': 'Creating…',
|
||||||
|
'settings.mcp.modal.create': 'Create Token',
|
||||||
|
'settings.mcp.modal.createdTitle': 'Token Created',
|
||||||
|
'settings.mcp.modal.createdWarning': 'This token will only be shown once. Copy and store it now — it cannot be recovered.',
|
||||||
|
'settings.mcp.modal.done': 'Done',
|
||||||
|
'settings.mcp.toast.created': 'Token created',
|
||||||
|
'settings.mcp.toast.createError': 'Failed to create token',
|
||||||
|
'settings.mcp.toast.deleted': 'Token deleted',
|
||||||
|
'settings.mcp.toast.deleteError': 'Failed to delete token',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
|
'settings.about': 'About',
|
||||||
'settings.username': 'Username',
|
'settings.username': 'Username',
|
||||||
'settings.email': 'Email',
|
'settings.email': 'Email',
|
||||||
'settings.role': 'Role',
|
'settings.role': 'Role',
|
||||||
@@ -145,8 +257,9 @@ const en: Record<string, string> = {
|
|||||||
'settings.passwordRequired': 'Please enter current and new password',
|
'settings.passwordRequired': 'Please enter current and new password',
|
||||||
'settings.passwordTooShort': 'Password must be at least 8 characters',
|
'settings.passwordTooShort': 'Password must be at least 8 characters',
|
||||||
'settings.passwordMismatch': 'Passwords do not match',
|
'settings.passwordMismatch': 'Passwords do not match',
|
||||||
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
|
'settings.passwordWeak': 'Password must contain uppercase, lowercase, a number, and a special character',
|
||||||
'settings.passwordChanged': 'Password changed successfully',
|
'settings.passwordChanged': 'Password changed successfully',
|
||||||
|
'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.',
|
||||||
'settings.deleteAccount': 'Delete account',
|
'settings.deleteAccount': 'Delete account',
|
||||||
'settings.deleteAccountTitle': 'Delete your account?',
|
'settings.deleteAccountTitle': 'Delete your account?',
|
||||||
'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
|
'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
|
||||||
@@ -164,6 +277,30 @@ const en: Record<string, string> = {
|
|||||||
'settings.avatarUploaded': 'Profile picture updated',
|
'settings.avatarUploaded': 'Profile picture updated',
|
||||||
'settings.avatarRemoved': 'Profile picture removed',
|
'settings.avatarRemoved': 'Profile picture removed',
|
||||||
'settings.avatarError': 'Upload failed',
|
'settings.avatarError': 'Upload failed',
|
||||||
|
'settings.mfa.title': 'Two-factor authentication (2FA)',
|
||||||
|
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Your administrator requires two-factor authentication. Set up an authenticator app below before continuing.',
|
||||||
|
'settings.mfa.backupTitle': 'Backup codes',
|
||||||
|
'settings.mfa.backupDescription': 'Use these one-time backup codes if you lose access to your authenticator app.',
|
||||||
|
'settings.mfa.backupWarning': 'Save these codes now. Each code can only be used once.',
|
||||||
|
'settings.mfa.backupCopy': 'Copy codes',
|
||||||
|
'settings.mfa.backupDownload': 'Download TXT',
|
||||||
|
'settings.mfa.backupPrint': 'Print / PDF',
|
||||||
|
'settings.mfa.backupCopied': 'Backup codes copied',
|
||||||
|
'settings.mfa.enabled': '2FA is enabled on your account.',
|
||||||
|
'settings.mfa.disabled': '2FA is not enabled.',
|
||||||
|
'settings.mfa.setup': 'Set up authenticator',
|
||||||
|
'settings.mfa.scanQr': 'Scan this QR code with your app, or enter the secret manually.',
|
||||||
|
'settings.mfa.secretLabel': 'Secret key (manual entry)',
|
||||||
|
'settings.mfa.codePlaceholder': '6-digit code',
|
||||||
|
'settings.mfa.enable': 'Enable 2FA',
|
||||||
|
'settings.mfa.cancelSetup': 'Cancel',
|
||||||
|
'settings.mfa.disableTitle': 'Disable 2FA',
|
||||||
|
'settings.mfa.disableHint': 'Enter your account password and a current code from your authenticator.',
|
||||||
|
'settings.mfa.disable': 'Disable 2FA',
|
||||||
|
'settings.mfa.toastEnabled': 'Two-factor authentication enabled',
|
||||||
|
'settings.mfa.toastDisabled': 'Two-factor authentication disabled',
|
||||||
|
'settings.mfa.demoBlocked': 'Not available in demo mode',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login.error': 'Login failed. Please check your credentials.',
|
'login.error': 'Login failed. Please check your credentials.',
|
||||||
@@ -191,7 +328,9 @@ const en: Record<string, string> = {
|
|||||||
'login.signingIn': 'Signing in…',
|
'login.signingIn': 'Signing in…',
|
||||||
'login.signIn': 'Sign In',
|
'login.signIn': 'Sign In',
|
||||||
'login.createAdmin': 'Create Admin Account',
|
'login.createAdmin': 'Create Admin Account',
|
||||||
'login.createAdminHint': 'Set up the first admin account for NOMAD.',
|
'login.createAdminHint': 'Set up the first admin account for TREK.',
|
||||||
|
'login.setNewPassword': 'Set New Password',
|
||||||
|
'login.setNewPasswordHint': 'You must change your password before continuing.',
|
||||||
'login.createAccount': 'Create Account',
|
'login.createAccount': 'Create Account',
|
||||||
'login.createAccountHint': 'Register a new account.',
|
'login.createAccountHint': 'Register a new account.',
|
||||||
'login.creating': 'Creating…',
|
'login.creating': 'Creating…',
|
||||||
@@ -206,11 +345,19 @@ const en: Record<string, string> = {
|
|||||||
'login.oidc.invalidState': 'Invalid session. Please try again.',
|
'login.oidc.invalidState': 'Invalid session. Please try again.',
|
||||||
'login.demoFailed': 'Demo login failed',
|
'login.demoFailed': 'Demo login failed',
|
||||||
'login.oidcSignIn': 'Sign in with {name}',
|
'login.oidcSignIn': 'Sign in with {name}',
|
||||||
|
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
|
||||||
'login.demoHint': 'Try the demo — no registration needed',
|
'login.demoHint': 'Try the demo — no registration needed',
|
||||||
|
'login.mfaTitle': 'Two-factor authentication',
|
||||||
|
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
|
||||||
|
'login.mfaCodeLabel': 'Verification code',
|
||||||
|
'login.mfaCodeRequired': 'Enter the code from your authenticator app.',
|
||||||
|
'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.',
|
||||||
|
'login.mfaBack': '← Back to sign in',
|
||||||
|
'login.mfaVerify': 'Verify',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Passwords do not match',
|
'register.passwordMismatch': 'Passwords do not match',
|
||||||
'register.passwordTooShort': 'Password must be at least 6 characters',
|
'register.passwordTooShort': 'Password must be at least 8 characters',
|
||||||
'register.failed': 'Registration failed',
|
'register.failed': 'Registration failed',
|
||||||
'register.getStarted': 'Get Started',
|
'register.getStarted': 'Get Started',
|
||||||
'register.subtitle': 'Create an account and start planning your dream trips.',
|
'register.subtitle': 'Create an account and start planning your dream trips.',
|
||||||
@@ -236,6 +383,7 @@ const en: Record<string, string> = {
|
|||||||
'admin.tabs.users': 'Users',
|
'admin.tabs.users': 'Users',
|
||||||
'admin.tabs.categories': 'Categories',
|
'admin.tabs.categories': 'Categories',
|
||||||
'admin.tabs.backup': 'Backup',
|
'admin.tabs.backup': 'Backup',
|
||||||
|
'admin.tabs.audit': 'Audit log',
|
||||||
'admin.stats.users': 'Users',
|
'admin.stats.users': 'Users',
|
||||||
'admin.stats.trips': 'Trips',
|
'admin.stats.trips': 'Trips',
|
||||||
'admin.stats.places': 'Places',
|
'admin.stats.places': 'Places',
|
||||||
@@ -264,9 +412,29 @@ const en: Record<string, string> = {
|
|||||||
'admin.toast.createError': 'Failed to create user',
|
'admin.toast.createError': 'Failed to create user',
|
||||||
'admin.toast.fieldsRequired': 'Username, email and password are required',
|
'admin.toast.fieldsRequired': 'Username, email and password are required',
|
||||||
'admin.createUser': 'Create User',
|
'admin.createUser': 'Create User',
|
||||||
|
'admin.invite.title': 'Invite Links',
|
||||||
|
'admin.invite.subtitle': 'Create one-time registration links',
|
||||||
|
'admin.invite.create': 'Create Link',
|
||||||
|
'admin.invite.createAndCopy': 'Create & Copy',
|
||||||
|
'admin.invite.empty': 'No invite links created yet',
|
||||||
|
'admin.invite.maxUses': 'Max. Uses',
|
||||||
|
'admin.invite.expiry': 'Expires after',
|
||||||
|
'admin.invite.uses': 'used',
|
||||||
|
'admin.invite.expiresAt': 'expires',
|
||||||
|
'admin.invite.createdBy': 'by',
|
||||||
|
'admin.invite.active': 'Active',
|
||||||
|
'admin.invite.expired': 'Expired',
|
||||||
|
'admin.invite.usedUp': 'Used up',
|
||||||
|
'admin.invite.copied': 'Invite link copied to clipboard',
|
||||||
|
'admin.invite.copyLink': 'Copy link',
|
||||||
|
'admin.invite.deleted': 'Invite link deleted',
|
||||||
|
'admin.invite.createError': 'Failed to create invite link',
|
||||||
|
'admin.invite.deleteError': 'Failed to delete invite link',
|
||||||
'admin.tabs.settings': 'Settings',
|
'admin.tabs.settings': 'Settings',
|
||||||
'admin.allowRegistration': 'Allow Registration',
|
'admin.allowRegistration': 'Allow Registration',
|
||||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||||
|
'admin.requireMfa': 'Require two-factor authentication (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.',
|
||||||
'admin.apiKeys': 'API Keys',
|
'admin.apiKeys': 'API Keys',
|
||||||
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
||||||
'admin.mapsKey': 'Google Maps API Key',
|
'admin.mapsKey': 'Google Maps API Key',
|
||||||
@@ -285,6 +453,8 @@ const en: Record<string, string> = {
|
|||||||
'admin.oidcIssuer': 'Issuer URL',
|
'admin.oidcIssuer': 'Issuer URL',
|
||||||
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
|
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
|
||||||
'admin.oidcSaved': 'OIDC configuration saved',
|
'admin.oidcSaved': 'OIDC configuration saved',
|
||||||
|
'admin.oidcOnlyMode': 'Disable password authentication',
|
||||||
|
'admin.oidcOnlyModeHint': 'When enabled, only SSO login is permitted. Password-based login and registration are blocked.',
|
||||||
|
|
||||||
// File Types
|
// File Types
|
||||||
'admin.fileTypes': 'Allowed File Types',
|
'admin.fileTypes': 'Allowed File Types',
|
||||||
@@ -292,25 +462,66 @@ const en: Record<string, string> = {
|
|||||||
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
||||||
'admin.fileTypesSaved': 'File type settings saved',
|
'admin.fileTypesSaved': 'File type settings saved',
|
||||||
|
|
||||||
|
// Packing Templates & Bag Tracking
|
||||||
|
'admin.bagTracking.title': 'Bag Tracking',
|
||||||
|
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
|
||||||
|
'admin.tabs.config': 'Configuration',
|
||||||
|
'admin.tabs.templates': 'Packing Templates',
|
||||||
|
'admin.packingTemplates.title': 'Packing Templates',
|
||||||
|
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
|
||||||
|
'admin.packingTemplates.create': 'New Template',
|
||||||
|
'admin.packingTemplates.namePlaceholder': 'Template name (e.g. Beach Holiday)',
|
||||||
|
'admin.packingTemplates.empty': 'No templates created yet',
|
||||||
|
'admin.packingTemplates.items': 'items',
|
||||||
|
'admin.packingTemplates.categories': 'categories',
|
||||||
|
'admin.packingTemplates.itemName': 'Item name',
|
||||||
|
'admin.packingTemplates.itemCategory': 'Category',
|
||||||
|
'admin.packingTemplates.categoryName': 'Category name (e.g. Clothing)',
|
||||||
|
'admin.packingTemplates.addCategory': 'Add category',
|
||||||
|
'admin.packingTemplates.created': 'Template created',
|
||||||
|
'admin.packingTemplates.deleted': 'Template deleted',
|
||||||
|
'admin.packingTemplates.loadError': 'Failed to load templates',
|
||||||
|
'admin.packingTemplates.createError': 'Failed to create template',
|
||||||
|
'admin.packingTemplates.deleteError': 'Failed to delete template',
|
||||||
|
'admin.packingTemplates.saveError': 'Failed to save',
|
||||||
|
|
||||||
// Addons
|
// Addons
|
||||||
'admin.tabs.addons': 'Addons',
|
'admin.tabs.addons': 'Addons',
|
||||||
'admin.addons.title': 'Addons',
|
'admin.addons.title': 'Addons',
|
||||||
'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.',
|
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
|
||||||
|
'admin.addons.catalog.packing.name': 'Packing',
|
||||||
|
'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
|
||||||
|
'admin.addons.catalog.budget.name': 'Budget',
|
||||||
|
'admin.addons.catalog.budget.description': 'Track expenses and plan your trip budget',
|
||||||
|
'admin.addons.catalog.documents.name': 'Documents',
|
||||||
|
'admin.addons.catalog.documents.description': 'Store and manage travel documents',
|
||||||
|
'admin.addons.catalog.vacay.name': 'Vacay',
|
||||||
|
'admin.addons.catalog.vacay.description': 'Personal vacation planner with calendar view',
|
||||||
|
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||||
|
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
|
||||||
|
'admin.addons.catalog.collab.name': 'Collab',
|
||||||
|
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
||||||
|
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||||
|
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
|
||||||
|
'admin.addons.catalog.mcp.name': 'MCP',
|
||||||
|
'admin.addons.catalog.mcp.description': 'Model Context Protocol for AI assistant integration',
|
||||||
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
||||||
'admin.addons.subtitleAfter': ' experience.',
|
'admin.addons.subtitleAfter': ' experience.',
|
||||||
'admin.addons.enabled': 'Enabled',
|
'admin.addons.enabled': 'Enabled',
|
||||||
'admin.addons.disabled': 'Disabled',
|
'admin.addons.disabled': 'Disabled',
|
||||||
'admin.addons.type.trip': 'Trip',
|
'admin.addons.type.trip': 'Trip',
|
||||||
'admin.addons.type.global': 'Global',
|
'admin.addons.type.global': 'Global',
|
||||||
|
'admin.addons.type.integration': 'Integration',
|
||||||
'admin.addons.tripHint': 'Available as a tab within each trip',
|
'admin.addons.tripHint': 'Available as a tab within each trip',
|
||||||
'admin.addons.globalHint': 'Available as a standalone section in the main navigation',
|
'admin.addons.globalHint': 'Available as a standalone section in the main navigation',
|
||||||
|
'admin.addons.integrationHint': 'Backend services and API integrations with no dedicated page',
|
||||||
'admin.addons.toast.updated': 'Addon updated',
|
'admin.addons.toast.updated': 'Addon updated',
|
||||||
'admin.addons.toast.error': 'Failed to update addon',
|
'admin.addons.toast.error': 'Failed to update addon',
|
||||||
'admin.addons.noAddons': 'No addons available',
|
'admin.addons.noAddons': 'No addons available',
|
||||||
// Weather info
|
// Weather info
|
||||||
'admin.weather.title': 'Weather Data',
|
'admin.weather.title': 'Weather Data',
|
||||||
'admin.weather.badge': 'Since March 24, 2026',
|
'admin.weather.badge': 'Since March 24, 2026',
|
||||||
'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
|
'admin.weather.description': 'TREK uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
|
||||||
'admin.weather.forecast': '16-day forecast',
|
'admin.weather.forecast': '16-day forecast',
|
||||||
'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
|
'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
|
||||||
'admin.weather.climate': 'Historical climate data',
|
'admin.weather.climate': 'Historical climate data',
|
||||||
@@ -320,7 +531,33 @@ const en: Record<string, string> = {
|
|||||||
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
||||||
|
|
||||||
// GitHub
|
// GitHub
|
||||||
|
'admin.tabs.mcpTokens': 'MCP Tokens',
|
||||||
|
'admin.mcpTokens.title': 'MCP Tokens',
|
||||||
|
'admin.mcpTokens.subtitle': 'Manage API tokens across all users',
|
||||||
|
'admin.mcpTokens.owner': 'Owner',
|
||||||
|
'admin.mcpTokens.tokenName': 'Token Name',
|
||||||
|
'admin.mcpTokens.created': 'Created',
|
||||||
|
'admin.mcpTokens.lastUsed': 'Last Used',
|
||||||
|
'admin.mcpTokens.never': 'Never',
|
||||||
|
'admin.mcpTokens.empty': 'No MCP tokens have been created yet',
|
||||||
|
'admin.mcpTokens.deleteTitle': 'Delete Token',
|
||||||
|
'admin.mcpTokens.deleteMessage': 'This will revoke the token immediately. The user will lose MCP access through this token.',
|
||||||
|
'admin.mcpTokens.deleteSuccess': 'Token deleted',
|
||||||
|
'admin.mcpTokens.deleteError': 'Failed to delete token',
|
||||||
|
'admin.mcpTokens.loadError': 'Failed to load tokens',
|
||||||
'admin.tabs.github': 'GitHub',
|
'admin.tabs.github': 'GitHub',
|
||||||
|
|
||||||
|
'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
|
||||||
|
'admin.audit.empty': 'No audit entries yet.',
|
||||||
|
'admin.audit.refresh': 'Refresh',
|
||||||
|
'admin.audit.loadMore': 'Load more',
|
||||||
|
'admin.audit.showing': '{count} loaded · {total} total',
|
||||||
|
'admin.audit.col.time': 'Time',
|
||||||
|
'admin.audit.col.user': 'User',
|
||||||
|
'admin.audit.col.action': 'Action',
|
||||||
|
'admin.audit.col.resource': 'Resource',
|
||||||
|
'admin.audit.col.ip': 'IP',
|
||||||
|
'admin.audit.col.details': 'Details',
|
||||||
'admin.github.title': 'Release History',
|
'admin.github.title': 'Release History',
|
||||||
'admin.github.subtitle': 'Latest updates from {repo}',
|
'admin.github.subtitle': 'Latest updates from {repo}',
|
||||||
'admin.github.latest': 'Latest',
|
'admin.github.latest': 'Latest',
|
||||||
@@ -331,13 +568,14 @@ const en: Record<string, string> = {
|
|||||||
'admin.github.loading': 'Loading...',
|
'admin.github.loading': 'Loading...',
|
||||||
'admin.github.error': 'Failed to load releases',
|
'admin.github.error': 'Failed to load releases',
|
||||||
'admin.github.by': 'by',
|
'admin.github.by': 'by',
|
||||||
|
'admin.github.support': 'Helps me keep building TREK',
|
||||||
|
|
||||||
'admin.update.available': 'Update available',
|
'admin.update.available': 'Update available',
|
||||||
'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
|
'admin.update.text': 'TREK {version} is available. You are running {current}.',
|
||||||
'admin.update.button': 'View on GitHub',
|
'admin.update.button': 'View on GitHub',
|
||||||
'admin.update.install': 'Install Update',
|
'admin.update.install': 'Install Update',
|
||||||
'admin.update.confirmTitle': 'Install Update?',
|
'admin.update.confirmTitle': 'Install Update?',
|
||||||
'admin.update.confirmText': 'NOMAD will be updated from {current} to {version}. The server will restart automatically afterwards.',
|
'admin.update.confirmText': 'TREK will be updated from {current} to {version}. The server will restart automatically afterwards.',
|
||||||
'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.',
|
'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.',
|
||||||
'admin.update.warning': 'The app will be briefly unavailable during the restart.',
|
'admin.update.warning': 'The app will be briefly unavailable during the restart.',
|
||||||
'admin.update.confirm': 'Update Now',
|
'admin.update.confirm': 'Update Now',
|
||||||
@@ -347,14 +585,15 @@ const en: Record<string, string> = {
|
|||||||
'admin.update.backupHint': 'We recommend creating a backup before updating.',
|
'admin.update.backupHint': 'We recommend creating a backup before updating.',
|
||||||
'admin.update.backupLink': 'Go to Backup',
|
'admin.update.backupLink': 'Go to Backup',
|
||||||
'admin.update.howTo': 'How to Update',
|
'admin.update.howTo': 'How to Update',
|
||||||
'admin.update.dockerText': 'Your NOMAD instance runs in Docker. To update to {version}, run the following commands on your server:',
|
'admin.update.dockerText': 'Your TREK instance runs in Docker. To update to {version}, run the following commands on your server:',
|
||||||
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
|
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
|
||||||
|
|
||||||
// Vacay addon
|
// Vacay addon
|
||||||
'vacay.subtitle': 'Plan and manage vacation days',
|
'vacay.subtitle': 'Plan and manage vacation days',
|
||||||
'vacay.settings': 'Settings',
|
'vacay.settings': 'Settings',
|
||||||
'vacay.year': 'Year',
|
'vacay.year': 'Year',
|
||||||
'vacay.addYear': 'Add year',
|
'vacay.addYear': 'Add next year',
|
||||||
|
'vacay.addPrevYear': 'Add previous year',
|
||||||
'vacay.removeYear': 'Remove year',
|
'vacay.removeYear': 'Remove year',
|
||||||
'vacay.removeYearConfirm': 'Remove {year}?',
|
'vacay.removeYearConfirm': 'Remove {year}?',
|
||||||
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
|
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
|
||||||
@@ -382,20 +621,32 @@ const en: Record<string, string> = {
|
|||||||
'vacay.remaining': 'Left',
|
'vacay.remaining': 'Left',
|
||||||
'vacay.carriedOver': 'from {year}',
|
'vacay.carriedOver': 'from {year}',
|
||||||
'vacay.blockWeekends': 'Block Weekends',
|
'vacay.blockWeekends': 'Block Weekends',
|
||||||
'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays',
|
'vacay.blockWeekendsHint': 'Prevent vacation entries on weekend days',
|
||||||
|
'vacay.weekendDays': 'Weekend days',
|
||||||
|
'vacay.mon': 'Mon',
|
||||||
|
'vacay.tue': 'Tue',
|
||||||
|
'vacay.wed': 'Wed',
|
||||||
|
'vacay.thu': 'Thu',
|
||||||
|
'vacay.fri': 'Fri',
|
||||||
|
'vacay.sat': 'Sat',
|
||||||
|
'vacay.sun': 'Sun',
|
||||||
'vacay.publicHolidays': 'Public Holidays',
|
'vacay.publicHolidays': 'Public Holidays',
|
||||||
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
||||||
'vacay.selectCountry': 'Select country',
|
'vacay.selectCountry': 'Select country',
|
||||||
'vacay.selectRegion': 'Select region (optional)',
|
'vacay.selectRegion': 'Select region (optional)',
|
||||||
|
'vacay.addCalendar': 'Add calendar',
|
||||||
|
'vacay.calendarLabel': 'Label (optional)',
|
||||||
|
'vacay.calendarColor': 'Color',
|
||||||
|
'vacay.noCalendars': 'No holiday calendars added yet',
|
||||||
'vacay.companyHolidays': 'Company Holidays',
|
'vacay.companyHolidays': 'Company Holidays',
|
||||||
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
|
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
|
||||||
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
|
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
|
||||||
'vacay.carryOver': 'Carry Over',
|
'vacay.carryOver': 'Carry Over',
|
||||||
'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year',
|
'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year',
|
||||||
'vacay.sharing': 'Sharing',
|
'vacay.sharing': 'Sharing',
|
||||||
'vacay.sharingHint': 'Share your vacation plan with other NOMAD users',
|
'vacay.sharingHint': 'Share your vacation plan with other TREK users',
|
||||||
'vacay.owner': 'Owner',
|
'vacay.owner': 'Owner',
|
||||||
'vacay.shareEmailPlaceholder': 'Email of NOMAD user',
|
'vacay.shareEmailPlaceholder': 'Email of TREK user',
|
||||||
'vacay.shareSuccess': 'Plan shared successfully',
|
'vacay.shareSuccess': 'Plan shared successfully',
|
||||||
'vacay.shareError': 'Could not share plan',
|
'vacay.shareError': 'Could not share plan',
|
||||||
'vacay.dissolve': 'Dissolve Fusion',
|
'vacay.dissolve': 'Dissolve Fusion',
|
||||||
@@ -407,7 +658,7 @@ const en: Record<string, string> = {
|
|||||||
'vacay.noData': 'No data',
|
'vacay.noData': 'No data',
|
||||||
'vacay.changeColor': 'Change color',
|
'vacay.changeColor': 'Change color',
|
||||||
'vacay.inviteUser': 'Invite User',
|
'vacay.inviteUser': 'Invite User',
|
||||||
'vacay.inviteHint': 'Invite another NOMAD user to share a combined vacation calendar.',
|
'vacay.inviteHint': 'Invite another TREK user to share a combined vacation calendar.',
|
||||||
'vacay.selectUser': 'Select user',
|
'vacay.selectUser': 'Select user',
|
||||||
'vacay.sendInvite': 'Send Invite',
|
'vacay.sendInvite': 'Send Invite',
|
||||||
'vacay.inviteSent': 'Invite sent',
|
'vacay.inviteSent': 'Invite sent',
|
||||||
@@ -431,6 +682,25 @@ const en: Record<string, string> = {
|
|||||||
'atlas.countries': 'Countries',
|
'atlas.countries': 'Countries',
|
||||||
'atlas.trips': 'Trips',
|
'atlas.trips': 'Trips',
|
||||||
'atlas.places': 'Places',
|
'atlas.places': 'Places',
|
||||||
|
'atlas.unmark': 'Remove',
|
||||||
|
'atlas.confirmMark': 'Mark this country as visited?',
|
||||||
|
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
||||||
|
'atlas.markVisited': 'Mark as visited',
|
||||||
|
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||||
|
'atlas.addToBucket': 'Add to bucket list',
|
||||||
|
'atlas.addPoi': 'Add place',
|
||||||
|
'atlas.searchCountry': 'Search a country...',
|
||||||
|
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
|
||||||
|
'atlas.month': 'Month',
|
||||||
|
'atlas.year': 'Year',
|
||||||
|
'atlas.addToBucketHint': 'Save as a place you want to visit',
|
||||||
|
'atlas.bucketWhen': 'When do you plan to visit?',
|
||||||
|
'atlas.statsTab': 'Stats',
|
||||||
|
'atlas.bucketTab': 'Bucket List',
|
||||||
|
'atlas.addBucket': 'Add to bucket list',
|
||||||
|
'atlas.bucketNotesPlaceholder': 'Notes (optional)',
|
||||||
|
'atlas.bucketEmpty': 'Your bucket list is empty',
|
||||||
|
'atlas.bucketEmptyHint': 'Add places you dream of visiting',
|
||||||
'atlas.days': 'Days',
|
'atlas.days': 'Days',
|
||||||
'atlas.visitedCountries': 'Visited Countries',
|
'atlas.visitedCountries': 'Visited Countries',
|
||||||
'atlas.cities': 'Cities',
|
'atlas.cities': 'Cities',
|
||||||
@@ -440,7 +710,6 @@ const en: Record<string, string> = {
|
|||||||
'atlas.nextTrip': 'Next trip',
|
'atlas.nextTrip': 'Next trip',
|
||||||
'atlas.daysLeft': 'days left',
|
'atlas.daysLeft': 'days left',
|
||||||
'atlas.streak': 'Streak',
|
'atlas.streak': 'Streak',
|
||||||
'atlas.year': 'year',
|
|
||||||
'atlas.years': 'years',
|
'atlas.years': 'years',
|
||||||
'atlas.yearInRow': 'year in a row',
|
'atlas.yearInRow': 'year in a row',
|
||||||
'atlas.yearsInRow': 'years in a row',
|
'atlas.yearsInRow': 'years in a row',
|
||||||
@@ -470,6 +739,7 @@ const en: Record<string, string> = {
|
|||||||
'trip.tabs.budget': 'Budget',
|
'trip.tabs.budget': 'Budget',
|
||||||
'trip.tabs.files': 'Files',
|
'trip.tabs.files': 'Files',
|
||||||
'trip.loading': 'Loading trip...',
|
'trip.loading': 'Loading trip...',
|
||||||
|
'trip.loadingPhotos': 'Loading place photos...',
|
||||||
'trip.mobilePlan': 'Plan',
|
'trip.mobilePlan': 'Plan',
|
||||||
'trip.mobilePlaces': 'Places',
|
'trip.mobilePlaces': 'Places',
|
||||||
'trip.toast.placeUpdated': 'Place updated',
|
'trip.toast.placeUpdated': 'Place updated',
|
||||||
@@ -485,6 +755,12 @@ const en: Record<string, string> = {
|
|||||||
|
|
||||||
// Day Plan Sidebar
|
// Day Plan Sidebar
|
||||||
'dayplan.emptyDay': 'No places planned for this day',
|
'dayplan.emptyDay': 'No places planned for this day',
|
||||||
|
'dayplan.cannotReorderTransport': 'Bookings with a fixed time cannot be reordered',
|
||||||
|
'dayplan.confirmRemoveTimeTitle': 'Remove time?',
|
||||||
|
'dayplan.confirmRemoveTimeBody': 'This place has a fixed time ({time}). Moving it will remove the time and allow free sorting.',
|
||||||
|
'dayplan.confirmRemoveTimeAction': 'Remove time & move',
|
||||||
|
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
|
||||||
|
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
|
||||||
'dayplan.addNote': 'Add Note',
|
'dayplan.addNote': 'Add Note',
|
||||||
'dayplan.editNote': 'Edit Note',
|
'dayplan.editNote': 'Edit Note',
|
||||||
'dayplan.noteAdd': 'Add Note',
|
'dayplan.noteAdd': 'Add Note',
|
||||||
@@ -510,11 +786,22 @@ const en: Record<string, string> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Add Place/Activity',
|
'places.addPlace': 'Add Place/Activity',
|
||||||
|
'places.importGpx': 'GPX',
|
||||||
|
'places.gpxImported': '{count} places imported from GPX',
|
||||||
|
'places.urlResolved': 'Place imported from URL',
|
||||||
|
'places.gpxError': 'GPX import failed',
|
||||||
|
'places.importGoogleList': 'Google List',
|
||||||
|
'places.googleListHint': 'Paste a shared Google Maps list link to import all places.',
|
||||||
|
'places.googleListImported': '{count} places imported from "{list}"',
|
||||||
|
'places.googleListError': 'Failed to import Google Maps list',
|
||||||
|
'places.viewDetails': 'View Details',
|
||||||
'places.assignToDay': 'Add to which day?',
|
'places.assignToDay': 'Add to which day?',
|
||||||
'places.all': 'All',
|
'places.all': 'All',
|
||||||
'places.unplanned': 'Unplanned',
|
'places.unplanned': 'Unplanned',
|
||||||
'places.search': 'Search places...',
|
'places.search': 'Search places...',
|
||||||
'places.allCategories': 'All Categories',
|
'places.allCategories': 'All Categories',
|
||||||
|
'places.categoriesSelected': 'categories',
|
||||||
|
'places.clearFilter': 'Clear filter',
|
||||||
'places.count': '{count} places',
|
'places.count': '{count} places',
|
||||||
'places.countSingular': '1 place',
|
'places.countSingular': '1 place',
|
||||||
'places.allPlanned': 'All places are planned',
|
'places.allPlanned': 'All places are planned',
|
||||||
@@ -563,6 +850,7 @@ const en: Record<string, string> = {
|
|||||||
'inspector.addRes': 'Reservation',
|
'inspector.addRes': 'Reservation',
|
||||||
'inspector.editRes': 'Edit Reservation',
|
'inspector.editRes': 'Edit Reservation',
|
||||||
'inspector.participants': 'Participants',
|
'inspector.participants': 'Participants',
|
||||||
|
'inspector.trackStats': 'Track Stats',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Bookings',
|
'reservations.title': 'Bookings',
|
||||||
@@ -586,8 +874,25 @@ const en: Record<string, string> = {
|
|||||||
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
||||||
'reservations.notes': 'Notes',
|
'reservations.notes': 'Notes',
|
||||||
'reservations.notesPlaceholder': 'Additional notes...',
|
'reservations.notesPlaceholder': 'Additional notes...',
|
||||||
|
'reservations.meta.airline': 'Airline',
|
||||||
|
'reservations.meta.flightNumber': 'Flight No.',
|
||||||
|
'reservations.meta.from': 'From',
|
||||||
|
'reservations.meta.to': 'To',
|
||||||
|
'reservations.meta.trainNumber': 'Train No.',
|
||||||
|
'reservations.meta.platform': 'Platform',
|
||||||
|
'reservations.meta.seat': 'Seat',
|
||||||
|
'reservations.meta.checkIn': 'Check-in',
|
||||||
|
'reservations.meta.checkOut': 'Check-out',
|
||||||
|
'reservations.meta.linkAccommodation': 'Accommodation',
|
||||||
|
'reservations.meta.pickAccommodation': 'Link to accommodation',
|
||||||
|
'reservations.meta.noAccommodation': 'None',
|
||||||
|
'reservations.meta.hotelPlace': 'Accommodation',
|
||||||
|
'reservations.meta.pickHotel': 'Select accommodation',
|
||||||
|
'reservations.meta.fromDay': 'From',
|
||||||
|
'reservations.meta.toDay': 'To',
|
||||||
|
'reservations.meta.selectDay': 'Select day',
|
||||||
'reservations.type.flight': 'Flight',
|
'reservations.type.flight': 'Flight',
|
||||||
'reservations.type.hotel': 'Hotel',
|
'reservations.type.hotel': 'Accommodation',
|
||||||
'reservations.type.restaurant': 'Restaurant',
|
'reservations.type.restaurant': 'Restaurant',
|
||||||
'reservations.type.train': 'Train',
|
'reservations.type.train': 'Train',
|
||||||
'reservations.type.car': 'Rental Car',
|
'reservations.type.car': 'Rental Car',
|
||||||
@@ -596,6 +901,8 @@ const en: Record<string, string> = {
|
|||||||
'reservations.type.tour': 'Tour',
|
'reservations.type.tour': 'Tour',
|
||||||
'reservations.type.other': 'Other',
|
'reservations.type.other': 'Other',
|
||||||
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
|
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
|
||||||
|
'reservations.confirm.deleteTitle': 'Delete booking?',
|
||||||
|
'reservations.confirm.deleteBody': '"{name}" will be permanently deleted.',
|
||||||
'reservations.toast.updated': 'Reservation updated',
|
'reservations.toast.updated': 'Reservation updated',
|
||||||
'reservations.toast.removed': 'Reservation deleted',
|
'reservations.toast.removed': 'Reservation deleted',
|
||||||
'reservations.toast.fileUploaded': 'File uploaded',
|
'reservations.toast.fileUploaded': 'File uploaded',
|
||||||
@@ -615,6 +922,7 @@ const en: Record<string, string> = {
|
|||||||
'reservations.pendingSave': 'will be saved…',
|
'reservations.pendingSave': 'will be saved…',
|
||||||
'reservations.uploading': 'Uploading...',
|
'reservations.uploading': 'Uploading...',
|
||||||
'reservations.attachFile': 'Attach file',
|
'reservations.attachFile': 'Attach file',
|
||||||
|
'reservations.linkExisting': 'Link existing file',
|
||||||
'reservations.toast.saveError': 'Failed to save',
|
'reservations.toast.saveError': 'Failed to save',
|
||||||
'reservations.toast.updateError': 'Failed to update',
|
'reservations.toast.updateError': 'Failed to update',
|
||||||
'reservations.toast.deleteError': 'Failed to delete',
|
'reservations.toast.deleteError': 'Failed to delete',
|
||||||
@@ -625,6 +933,7 @@ const en: Record<string, string> = {
|
|||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Budget',
|
'budget.title': 'Budget',
|
||||||
|
'budget.exportCsv': 'Export CSV',
|
||||||
'budget.emptyTitle': 'No budget created yet',
|
'budget.emptyTitle': 'No budget created yet',
|
||||||
'budget.emptyText': 'Create categories and entries to plan your travel budget',
|
'budget.emptyText': 'Create categories and entries to plan your travel budget',
|
||||||
'budget.emptyPlaceholder': 'Enter category name...',
|
'budget.emptyPlaceholder': 'Enter category name...',
|
||||||
@@ -639,6 +948,7 @@ const en: Record<string, string> = {
|
|||||||
'budget.table.perDay': 'Per Day',
|
'budget.table.perDay': 'Per Day',
|
||||||
'budget.table.perPersonDay': 'P. p / Day',
|
'budget.table.perPersonDay': 'P. p / Day',
|
||||||
'budget.table.note': 'Note',
|
'budget.table.note': 'Note',
|
||||||
|
'budget.table.date': 'Date',
|
||||||
'budget.newEntry': 'New Entry',
|
'budget.newEntry': 'New Entry',
|
||||||
'budget.defaultEntry': 'New Entry',
|
'budget.defaultEntry': 'New Entry',
|
||||||
'budget.defaultCategory': 'New Category',
|
'budget.defaultCategory': 'New Category',
|
||||||
@@ -652,6 +962,9 @@ const en: Record<string, string> = {
|
|||||||
'budget.paid': 'Paid',
|
'budget.paid': 'Paid',
|
||||||
'budget.open': 'Open',
|
'budget.open': 'Open',
|
||||||
'budget.noMembers': 'No members assigned',
|
'budget.noMembers': 'No members assigned',
|
||||||
|
'budget.settlement': 'Settlement',
|
||||||
|
'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
|
||||||
|
'budget.netBalances': 'Net Balances',
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
'files.title': 'Files',
|
'files.title': 'Files',
|
||||||
@@ -679,10 +992,41 @@ const en: Record<string, string> = {
|
|||||||
'files.sourceBooking': 'Booking',
|
'files.sourceBooking': 'Booking',
|
||||||
'files.attach': 'Attach',
|
'files.attach': 'Attach',
|
||||||
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
|
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
|
||||||
|
'files.trash': 'Trash',
|
||||||
|
'files.trashEmpty': 'Trash is empty',
|
||||||
|
'files.emptyTrash': 'Empty Trash',
|
||||||
|
'files.restore': 'Restore',
|
||||||
|
'files.star': 'Star',
|
||||||
|
'files.unstar': 'Unstar',
|
||||||
|
'files.assign': 'Assign',
|
||||||
|
'files.assignTitle': 'Assign File',
|
||||||
|
'files.assignPlace': 'Place',
|
||||||
|
'files.assignBooking': 'Booking',
|
||||||
|
'files.unassigned': 'Unassigned',
|
||||||
|
'files.unlink': 'Remove link',
|
||||||
|
'files.toast.trashed': 'Moved to trash',
|
||||||
|
'files.toast.restored': 'File restored',
|
||||||
|
'files.toast.trashEmptied': 'Trash emptied',
|
||||||
|
'files.toast.assigned': 'File assigned',
|
||||||
|
'files.toast.assignError': 'Assignment failed',
|
||||||
|
'files.toast.restoreError': 'Restore failed',
|
||||||
|
'files.confirm.permanentDelete': 'Permanently delete this file? This cannot be undone.',
|
||||||
|
'files.confirm.emptyTrash': 'Permanently delete all trashed files? This cannot be undone.',
|
||||||
|
'files.noteLabel': 'Note',
|
||||||
|
'files.notePlaceholder': 'Add a note...',
|
||||||
|
|
||||||
// Packing
|
// Packing
|
||||||
'packing.title': 'Packing List',
|
'packing.title': 'Packing List',
|
||||||
'packing.empty': 'Packing list is empty',
|
'packing.empty': 'Packing list is empty',
|
||||||
|
'packing.import': 'Import',
|
||||||
|
'packing.importTitle': 'Import Packing List',
|
||||||
|
'packing.importHint': 'One item per line. Format: Category, Name, Weight in g (optional), Bag (optional), checked/unchecked (optional)',
|
||||||
|
'packing.importPlaceholder': 'Hygiene, Toothbrush\nClothing, T-Shirts, 200\nDocuments, Passport, , Carry-on\nElectronics, Charger, 50, Suitcase, checked',
|
||||||
|
'packing.importCsv': 'Load CSV/TXT',
|
||||||
|
'packing.importAction': 'Import {count}',
|
||||||
|
'packing.importSuccess': '{count} items imported',
|
||||||
|
'packing.importError': 'Import failed',
|
||||||
|
'packing.importEmpty': 'No items to import',
|
||||||
'packing.progress': '{packed} of {total} packed ({percent}%)',
|
'packing.progress': '{packed} of {total} packed ({percent}%)',
|
||||||
'packing.clearChecked': 'Remove {count} checked',
|
'packing.clearChecked': 'Remove {count} checked',
|
||||||
'packing.clearCheckedShort': 'Remove {count}',
|
'packing.clearCheckedShort': 'Remove {count}',
|
||||||
@@ -702,6 +1046,21 @@ const en: Record<string, string> = {
|
|||||||
'packing.menuCheckAll': 'Check All',
|
'packing.menuCheckAll': 'Check All',
|
||||||
'packing.menuUncheckAll': 'Uncheck All',
|
'packing.menuUncheckAll': 'Uncheck All',
|
||||||
'packing.menuDeleteCat': 'Delete Category',
|
'packing.menuDeleteCat': 'Delete Category',
|
||||||
|
'packing.assignUser': 'Assign user',
|
||||||
|
'packing.noMembers': 'No trip members',
|
||||||
|
'packing.addItem': 'Add item',
|
||||||
|
'packing.addItemPlaceholder': 'Item name...',
|
||||||
|
'packing.addCategory': 'Add category',
|
||||||
|
'packing.newCategoryPlaceholder': 'Category name (e.g. Clothing)',
|
||||||
|
'packing.applyTemplate': 'Apply template',
|
||||||
|
'packing.template': 'Template',
|
||||||
|
'packing.templateApplied': '{count} items added from template',
|
||||||
|
'packing.templateError': 'Failed to apply template',
|
||||||
|
'packing.bags': 'Bags',
|
||||||
|
'packing.noBag': 'Unassigned',
|
||||||
|
'packing.totalWeight': 'Total weight',
|
||||||
|
'packing.bagName': 'Bag name...',
|
||||||
|
'packing.addBag': 'Add bag',
|
||||||
'packing.changeCategory': 'Change Category',
|
'packing.changeCategory': 'Change Category',
|
||||||
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
|
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
|
||||||
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
|
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
|
||||||
@@ -819,7 +1178,27 @@ const en: Record<string, string> = {
|
|||||||
'backup.auto.enable': 'Enable auto-backup',
|
'backup.auto.enable': 'Enable auto-backup',
|
||||||
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
|
||||||
'backup.auto.interval': 'Interval',
|
'backup.auto.interval': 'Interval',
|
||||||
|
'backup.auto.hour': 'Run at hour',
|
||||||
|
'backup.auto.hourHint': 'Server local time ({format} format)',
|
||||||
|
'backup.auto.dayOfWeek': 'Day of week',
|
||||||
|
'backup.auto.dayOfMonth': 'Day of month',
|
||||||
|
'backup.auto.dayOfMonthHint': 'Limited to 1–28 for compatibility with all months',
|
||||||
|
'backup.auto.scheduleSummary': 'Schedule',
|
||||||
|
'backup.auto.summaryDaily': 'Every day at {hour}:00',
|
||||||
|
'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
|
||||||
|
'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
|
||||||
|
'backup.auto.envLocked': 'Docker',
|
||||||
|
'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
|
||||||
|
'backup.auto.copyEnv': 'Copy Docker env vars',
|
||||||
|
'backup.auto.envCopied': 'Docker env vars copied to clipboard',
|
||||||
'backup.auto.keepLabel': 'Delete old backups after',
|
'backup.auto.keepLabel': 'Delete old backups after',
|
||||||
|
'backup.dow.sunday': 'Sun',
|
||||||
|
'backup.dow.monday': 'Mon',
|
||||||
|
'backup.dow.tuesday': 'Tue',
|
||||||
|
'backup.dow.wednesday': 'Wed',
|
||||||
|
'backup.dow.thursday': 'Thu',
|
||||||
|
'backup.dow.friday': 'Fri',
|
||||||
|
'backup.dow.saturday': 'Sat',
|
||||||
'backup.interval.hourly': 'Hourly',
|
'backup.interval.hourly': 'Hourly',
|
||||||
'backup.interval.daily': 'Daily',
|
'backup.interval.daily': 'Daily',
|
||||||
'backup.interval.weekly': 'Weekly',
|
'backup.interval.weekly': 'Weekly',
|
||||||
@@ -946,6 +1325,60 @@ const en: Record<string, string> = {
|
|||||||
'day.editAccommodation': 'Edit accommodation',
|
'day.editAccommodation': 'Edit accommodation',
|
||||||
'day.reservations': 'Reservations',
|
'day.reservations': 'Reservations',
|
||||||
|
|
||||||
|
// Photos / Immich
|
||||||
|
'memories.title': 'Photos',
|
||||||
|
'memories.notConnected': 'Immich not connected',
|
||||||
|
'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.',
|
||||||
|
'memories.noDates': 'Add dates to your trip to load photos.',
|
||||||
|
'memories.noPhotos': 'No photos found',
|
||||||
|
'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.',
|
||||||
|
'memories.photosFound': 'photos',
|
||||||
|
'memories.fromOthers': 'from others',
|
||||||
|
'memories.sharePhotos': 'Share photos',
|
||||||
|
'memories.sharing': 'Sharing',
|
||||||
|
'memories.reviewTitle': 'Review your photos',
|
||||||
|
'memories.reviewHint': 'Click photos to exclude them from sharing.',
|
||||||
|
'memories.shareCount': 'Share {count} photos',
|
||||||
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
|
'memories.immichApiKey': 'API Key',
|
||||||
|
'memories.testConnection': 'Test connection',
|
||||||
|
'memories.testFirst': 'Test connection first',
|
||||||
|
'memories.connected': 'Connected',
|
||||||
|
'memories.disconnected': 'Not connected',
|
||||||
|
'memories.connectionSuccess': 'Connected to Immich',
|
||||||
|
'memories.connectionError': 'Could not connect to Immich',
|
||||||
|
'memories.saved': 'Immich settings saved',
|
||||||
|
'memories.addPhotos': 'Add photos',
|
||||||
|
'memories.linkAlbum': 'Link Album',
|
||||||
|
'memories.selectAlbum': 'Select Immich Album',
|
||||||
|
'memories.noAlbums': 'No albums found',
|
||||||
|
'memories.syncAlbum': 'Sync album',
|
||||||
|
'memories.unlinkAlbum': 'Unlink album',
|
||||||
|
'memories.photos': 'photos',
|
||||||
|
'memories.selectPhotos': 'Select photos from Immich',
|
||||||
|
'memories.selectHint': 'Tap photos to select them.',
|
||||||
|
'memories.selected': 'selected',
|
||||||
|
'memories.addSelected': 'Add {count} photos',
|
||||||
|
'memories.alreadyAdded': 'Added',
|
||||||
|
'memories.private': 'Private',
|
||||||
|
'memories.stopSharing': 'Stop sharing',
|
||||||
|
'memories.oldest': 'Oldest first',
|
||||||
|
'memories.newest': 'Newest first',
|
||||||
|
'memories.allLocations': 'All locations',
|
||||||
|
'memories.tripDates': 'Trip dates',
|
||||||
|
'memories.allPhotos': 'All photos',
|
||||||
|
'memories.confirmShareTitle': 'Share with trip members?',
|
||||||
|
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
|
||||||
|
'memories.confirmShareButton': 'Share photos',
|
||||||
|
'memories.error.loadAlbums': 'Failed to load albums',
|
||||||
|
'memories.error.linkAlbum': 'Failed to link album',
|
||||||
|
'memories.error.unlinkAlbum': 'Failed to unlink album',
|
||||||
|
'memories.error.syncAlbum': 'Failed to sync album',
|
||||||
|
'memories.error.loadPhotos': 'Failed to load photos',
|
||||||
|
'memories.error.addPhotos': 'Failed to add photos',
|
||||||
|
'memories.error.removePhoto': 'Failed to remove photo',
|
||||||
|
'memories.error.toggleSharing': 'Failed to update sharing',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notes',
|
'collab.tabs.notes': 'Notes',
|
||||||
@@ -964,11 +1397,11 @@ const en: Record<string, string> = {
|
|||||||
'collab.chat.today': 'Today',
|
'collab.chat.today': 'Today',
|
||||||
'collab.chat.yesterday': 'Yesterday',
|
'collab.chat.yesterday': 'Yesterday',
|
||||||
'collab.chat.deletedMessage': 'deleted a message',
|
'collab.chat.deletedMessage': 'deleted a message',
|
||||||
|
'collab.chat.reply': 'Reply',
|
||||||
'collab.chat.loadMore': 'Load older messages',
|
'collab.chat.loadMore': 'Load older messages',
|
||||||
'collab.chat.justNow': 'just now',
|
'collab.chat.justNow': 'just now',
|
||||||
'collab.chat.minutesAgo': '{n}m ago',
|
'collab.chat.minutesAgo': '{n}m ago',
|
||||||
'collab.chat.hoursAgo': '{n}h ago',
|
'collab.chat.hoursAgo': '{n}h ago',
|
||||||
'collab.chat.yesterday': 'yesterday',
|
|
||||||
'collab.notes.title': 'Notes',
|
'collab.notes.title': 'Notes',
|
||||||
'collab.notes.new': 'New Note',
|
'collab.notes.new': 'New Note',
|
||||||
'collab.notes.empty': 'No notes yet',
|
'collab.notes.empty': 'No notes yet',
|
||||||
@@ -1015,6 +1448,99 @@ const en: Record<string, string> = {
|
|||||||
'collab.polls.options': 'Options',
|
'collab.polls.options': 'Options',
|
||||||
'collab.polls.delete': 'Delete',
|
'collab.polls.delete': 'Delete',
|
||||||
'collab.polls.closedSection': 'Closed',
|
'collab.polls.closedSection': 'Closed',
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'admin.tabs.permissions': 'Permissions',
|
||||||
|
'perm.title': 'Permission Settings',
|
||||||
|
'perm.subtitle': 'Control who can perform actions across the application',
|
||||||
|
'perm.saved': 'Permission settings saved',
|
||||||
|
'perm.resetDefaults': 'Reset to defaults',
|
||||||
|
'perm.customized': 'customized',
|
||||||
|
'perm.level.admin': 'Admin only',
|
||||||
|
'perm.level.tripOwner': 'Trip owner',
|
||||||
|
'perm.level.tripMember': 'Trip members',
|
||||||
|
'perm.level.everybody': 'Everyone',
|
||||||
|
'perm.cat.trip': 'Trip Management',
|
||||||
|
'perm.cat.members': 'Member Management',
|
||||||
|
'perm.cat.files': 'Files',
|
||||||
|
'perm.cat.content': 'Content & Schedule',
|
||||||
|
'perm.cat.extras': 'Budget, Packing & Collaboration',
|
||||||
|
'perm.action.trip_create': 'Create trips',
|
||||||
|
'perm.action.trip_edit': 'Edit trip details',
|
||||||
|
'perm.action.trip_delete': 'Delete trips',
|
||||||
|
'perm.action.trip_archive': 'Archive / unarchive trips',
|
||||||
|
'perm.action.trip_cover_upload': 'Upload cover image',
|
||||||
|
'perm.action.member_manage': 'Add / remove members',
|
||||||
|
'perm.action.file_upload': 'Upload files',
|
||||||
|
'perm.action.file_edit': 'Edit file metadata',
|
||||||
|
'perm.action.file_delete': 'Delete files',
|
||||||
|
'perm.action.place_edit': 'Add / edit / delete places',
|
||||||
|
'perm.action.day_edit': 'Edit days, notes & assignments',
|
||||||
|
'perm.action.reservation_edit': 'Manage reservations',
|
||||||
|
'perm.action.budget_edit': 'Manage budget',
|
||||||
|
'perm.action.packing_edit': 'Manage packing lists',
|
||||||
|
'perm.action.collab_edit': 'Collaboration (notes, polls, chat)',
|
||||||
|
'perm.action.share_manage': 'Manage share links',
|
||||||
|
'perm.actionHint.trip_create': 'Who can create new trips',
|
||||||
|
'perm.actionHint.trip_edit': 'Who can change trip name, dates, description and currency',
|
||||||
|
'perm.actionHint.trip_delete': 'Who can permanently delete a trip',
|
||||||
|
'perm.actionHint.trip_archive': 'Who can archive or unarchive a trip',
|
||||||
|
'perm.actionHint.trip_cover_upload': 'Who can upload or change the cover image',
|
||||||
|
'perm.actionHint.member_manage': 'Who can invite or remove trip members',
|
||||||
|
'perm.actionHint.file_upload': 'Who can upload files to a trip',
|
||||||
|
'perm.actionHint.file_edit': 'Who can edit file descriptions and links',
|
||||||
|
'perm.actionHint.file_delete': 'Who can move files to trash or permanently delete them',
|
||||||
|
'perm.actionHint.place_edit': 'Who can add, edit or delete places',
|
||||||
|
'perm.actionHint.day_edit': 'Who can edit days, day notes and place assignments',
|
||||||
|
'perm.actionHint.reservation_edit': 'Who can create, edit or delete reservations',
|
||||||
|
'perm.actionHint.budget_edit': 'Who can create, edit or delete budget items',
|
||||||
|
'perm.actionHint.packing_edit': 'Who can manage packing items and bags',
|
||||||
|
'perm.actionHint.collab_edit': 'Who can create notes, polls and send messages',
|
||||||
|
'perm.actionHint.share_manage': 'Who can create or delete public share links',
|
||||||
|
|
||||||
|
// Undo
|
||||||
|
'undo.button': 'Undo',
|
||||||
|
'undo.tooltip': 'Undo: {action}',
|
||||||
|
'undo.assignPlace': 'Place assigned to day',
|
||||||
|
'undo.removeAssignment': 'Place removed from day',
|
||||||
|
'undo.reorder': 'Places reordered',
|
||||||
|
'undo.optimize': 'Route optimized',
|
||||||
|
'undo.deletePlace': 'Place deleted',
|
||||||
|
'undo.moveDay': 'Place moved to another day',
|
||||||
|
'undo.lock': 'Place lock toggled',
|
||||||
|
'undo.importGpx': 'GPX import',
|
||||||
|
'undo.importGoogleList': 'Google Maps import',
|
||||||
|
'undo.addPlace': 'Place added',
|
||||||
|
'undo.done': 'Undone: {action}',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
'notifications.title': 'Notifications',
|
||||||
|
'notifications.markAllRead': 'Mark all read',
|
||||||
|
'notifications.deleteAll': 'Delete all',
|
||||||
|
'notifications.showAll': 'Show all notifications',
|
||||||
|
'notifications.empty': 'No notifications',
|
||||||
|
'notifications.emptyDescription': "You're all caught up!",
|
||||||
|
'notifications.all': 'All',
|
||||||
|
'notifications.unreadOnly': 'Unread',
|
||||||
|
'notifications.markRead': 'Mark as read',
|
||||||
|
'notifications.markUnread': 'Mark as unread',
|
||||||
|
'notifications.delete': 'Delete',
|
||||||
|
'notifications.system': 'System',
|
||||||
|
|
||||||
|
// Notification test keys (dev only)
|
||||||
|
'notifications.test.title': 'Test notification from {actor}',
|
||||||
|
'notifications.test.text': 'This is a simple test notification.',
|
||||||
|
'notifications.test.booleanTitle': '{actor} asks for your approval',
|
||||||
|
'notifications.test.booleanText': 'This is a test boolean notification. Choose an action below.',
|
||||||
|
'notifications.test.accept': 'Approve',
|
||||||
|
'notifications.test.decline': 'Decline',
|
||||||
|
'notifications.test.navigateTitle': 'Check something out',
|
||||||
|
'notifications.test.navigateText': 'This is a test navigate notification.',
|
||||||
|
'notifications.test.goThere': 'Go there',
|
||||||
|
'notifications.test.adminTitle': 'Admin broadcast',
|
||||||
|
'notifications.test.adminText': '{actor} sent a test notification to all admins.',
|
||||||
|
'notifications.test.tripTitle': '{actor} posted in your trip',
|
||||||
|
'notifications.test.tripText': 'Test notification for trip "{trip}".',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default en
|
export default en
|
||||||
|
|||||||