mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
Compare commits
369 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 955776b492 | |||
| 9b11abbf4a | |||
| cc613771fa | |||
| 5cc81ae4b0 | |||
| 94b74f96a3 | |||
| 48bf149d01 | |||
| f3679739d8 | |||
| 38206883ff | |||
| dd21074c27 | |||
| cd5a6c7491 | |||
| 6e6e0a370e | |||
| 83bac11173 | |||
| ecf69225e1 | |||
| c6148ba4f2 | |||
| 9ee5d21c3a | |||
| d5cc2432c4 | |||
| 7f077d949d | |||
| 312bc715bf | |||
| 6ba08352ed | |||
| 58874a1ccb | |||
| 82f08360d7 | |||
| 978d26f36c | |||
| 18eee16d2d | |||
| c274846275 | |||
| 7821993450 | |||
| a9d6ce87c1 | |||
| 67b21d5fe3 | |||
| 8b488efc8e | |||
| 070b75b6be | |||
| 51c4afd5f7 | |||
| 74b3b0f9ae | |||
| 1236f3281d | |||
| 4a0d586768 | |||
| 079964bec8 | |||
| b0b85fff3a | |||
| 0d3a10120a | |||
| b8c3d5b3d1 | |||
| 959015928f | |||
| d8ee545002 | |||
| 78b9536de9 | |||
| 4e4afe2545 | |||
| 38afba0820 | |||
| 81742dbb85 | |||
| 3898e5f7e2 | |||
| 6a36efbf1a | |||
| 991b4065e3 | |||
| c158df1bc5 | |||
| f03705848d | |||
| 0c99eb1d07 | |||
| 7b37d337c1 | |||
| 69ae6f93db | |||
| 71c1683bb3 | |||
| 6df8b2555d | |||
| 16cadeb09e | |||
| fc29c5f7d0 | |||
| 399684cc19 | |||
| a038dbd8da | |||
| f225f45f50 | |||
| 58b7c2e7ac | |||
| b8058a2755 | |||
| aa244dd548 | |||
| 33d8953554 | |||
| c39ae2b965 | |||
| 3413d3f77d | |||
| c9e3185ad0 | |||
| f8cf37a9bd | |||
| 20709d23ee | |||
| e4065c276b | |||
| 11b6974387 | |||
| 554a7d7530 | |||
| 2baf407809 | |||
| 259ff53bfb | |||
| 21063e6230 | |||
| 1285da063e | |||
| 3e9e3fcc9e | |||
| ba4bfc693a | |||
| 179938e904 | |||
| 4e13a59338 | |||
| 2c9e71c91d | |||
| 733567d088 | |||
| 5b25c60b62 | |||
| d7efa9d914 | |||
| c70f5284c7 | |||
| b40bea036f | |||
| 6da7843bf0 | |||
| 9f0ec8199f | |||
| 9bff25558e | |||
| 00b96eb678 | |||
| 3d0249e076 | |||
| 1bddb3c588 | |||
| b26023e32a | |||
| c8421eb1fc | |||
| 8c125738e8 | |||
| 6d92e14515 | |||
| 0b36427c09 | |||
| 1ea0eb9965 | |||
| c4c3ea1e6d | |||
| 43c801232e | |||
| 6825a4a0c1 | |||
| 8a4a8b58be | |||
| be975f38a6 | |||
| fa37d5b3f7 | |||
| 0ddd0c14b2 | |||
| 297cfda32b | |||
| d8367ec878 | |||
| 79057327fa | |||
| 0943184b1e | |||
| 3f612c4d26 | |||
| a4752ae692 | |||
| e6068d44b0 | |||
| 877e1a09cc | |||
| bca82b3f8c | |||
| 1aea2fcee8 | |||
| 504713d920 | |||
| 50d2a211e5 | |||
| 5d3a740791 | |||
| 2c1c77f367 | |||
| 68f0d399ca | |||
| 1305a07502 | |||
| c9dd8e1192 | |||
| 860739b28b | |||
| 80d013dd19 | |||
| 2469739bca | |||
| 2197e0e1fd | |||
| 846db9d076 | |||
| a307d8d1c9 | |||
| ae0d48ac83 | |||
| 6400c2d27d | |||
| fc28996420 | |||
| 929105f0e4 | |||
| 93c0d6fe78 | |||
| 88a40c3294 | |||
| c056401000 | |||
| eae799c7d6 | |||
| 20ce7460c1 | |||
| d765a80ea3 | |||
| b6686a462f | |||
| 9ddb101135 | |||
| 1dc189b466 | |||
| e624ee337f | |||
| 6ba5df0215 | |||
| 897e1bff26 | |||
| ba14636c1d | |||
| 6c72295424 | |||
| f6faaa23b0 | |||
| ba737a9920 | |||
| 98813a9b40 | |||
| e0105115f4 | |||
| 7d51eadf90 | |||
| 66740887e7 | |||
| 69deaf9969 | |||
| 217458da81 | |||
| 61a5e42403 | |||
| 07546c4790 | |||
| f4f768a1b3 | |||
| a9c392e26e | |||
| 90af1332e8 | |||
| de4bdb4a99 | |||
| 8dd22ab8a3 | |||
| fa25ff29bb | |||
| 21f87d9b91 | |||
| 0115987e52 | |||
| 6c138ca924 | |||
| 1adc2fec86 | |||
| 8c7f8d6ad1 | |||
| 2ae9da3153 | |||
| b4741c31a9 | |||
| cfdbf9235f | |||
| 059158d087 | |||
| 77393ff40b | |||
| 64d4a20403 | |||
| 6b94c0632c | |||
| cb124ba3ec | |||
| ba01b4acac | |||
| ce72f45d9a | |||
| bf2eea18c3 | |||
| 501bab0f69 | |||
| 5dd80d5cb8 | |||
| 8f6de3cd23 | |||
| 816696d0fe | |||
| bb54fda6dc | |||
| 36f2292f2d | |||
| 905c7d460b | |||
| d48714d17a | |||
| a0db42fbfe | |||
| 82a3940a2c | |||
| b224f8b713 | |||
| be03fffcae | |||
| 1e27a62b53 | |||
| d418d85d02 | |||
| a7d3f9fc06 | |||
| 7a169d0596 | |||
| cf968969d0 | |||
| c20d0256c8 | |||
| c4236d6737 | |||
| 4b8cfc78b8 | |||
| f7c965bc6b | |||
| 78a91ccb95 | |||
| 8e9f8784dc | |||
| 5be2e9b268 | |||
| f4d0ccb454 | |||
| a40983e65e | |||
| f32c103fe1 | |||
| 0b77fe5292 | |||
| 9afb51fcc0 | |||
| 4e10028669 | |||
| d4e16ebe49 | |||
| 1e44b25a0c | |||
| 4ff03a1f2c | |||
| 40f7c00adb | |||
| b43d8d119f | |||
| 74e3f85866 | |||
| bbf3f0cae8 | |||
| c0e9a771d6 | |||
| c49272efc1 | |||
| 979322025d | |||
| f0131632a7 | |||
| ffe91604b5 | |||
| e7fa8f5da9 | |||
| 3256f5156d | |||
| d45073a0bd | |||
| a4d6348a79 | |||
| c944a7d101 | |||
| 45e0c7e546 | |||
| 32b63adc68 | |||
| b1cca15f6f | |||
| dfeb7b3db7 | |||
| 50424fc574 | |||
| 12a910876e | |||
| d73a5e223c | |||
| fd9567e3fe | |||
| ae04071466 | |||
| 2ab3f59722 | |||
| 7257fac859 | |||
| 1a4c04e239 | |||
| 39a495714f | |||
| fabf5a7e26 | |||
| e71bd6768e | |||
| 71403e6303 | |||
| 43fc4db00e | |||
| e9ee2d4b0d | |||
| 228cb05932 | |||
| 505bf04a1f | |||
| 41bfcf2f76 | |||
| e308204808 | |||
| 411d5408c1 | |||
| 45684d9e44 | |||
| 0ebcff9504 | |||
| edafe01387 | |||
| 16277a3811 | |||
| ef5b381f8e | |||
| ef9880a2a5 | |||
| 95cb81b0e5 | |||
| 7d0ae631b8 | |||
| 5c04074d54 | |||
| e89ba2ecfc | |||
| 4ebf9c5f11 | |||
| add0b17e04 | |||
| 60906cf1d1 | |||
| 9292acb979 | |||
| be57b7130f | |||
| b88a8fcbb5 | |||
| 040840917c | |||
| 44e5f07f59 | |||
| c9e61859ce | |||
| 862f59b77a | |||
| 871bfd7dfd | |||
| 4d596f2ff9 | |||
| 8c85ea3644 | |||
| 19350fbc3e | |||
| 358afd2428 | |||
| 7a314a92b1 | |||
| e03505dca2 | |||
| ce8d498f2d | |||
| b109c1340a | |||
| e10f6bf9af | |||
| 6f5550dc50 | |||
| dfdd473eca | |||
| b515880adb | |||
| 78695b4e03 | |||
| 0ee53e7b38 | |||
| 1b28bd96d4 | |||
| bba50f038b | |||
| 701a8ab03a | |||
| ccb5f9df1f | |||
| c9341eda3f | |||
| fb2e8d8209 | |||
| 27fb9246e6 | |||
| 9a2c7c5db6 | |||
| d1ad5da919 | |||
| 1fbc19ad4f | |||
| 23edfe3dfc | |||
| 1ff8546484 | |||
| 6d18d5ed2d | |||
| 6d5067247c | |||
| 5e05bcd0db | |||
| 5f71b85c06 | |||
| d74133745a | |||
| eee2bbe47a | |||
| c1bce755ca | |||
| 015be3d53a | |||
| 7d3b37a2a3 | |||
| ff1c1ed56a | |||
| d5674e9a11 | |||
| 7eabe65bcf | |||
| 3444e3f446 | |||
| 9e3ac1e490 | |||
| c38e70e244 | |||
| ce7215341f | |||
| 4733955531 | |||
| 36267de117 | |||
| cd13399da5 | |||
| 36cd2feca5 | |||
| fbe3b5b17e | |||
| 10107ecf31 | |||
| 94d698e39f | |||
| 6c88a01123 | |||
| 75af89de30 | |||
| ed8518aca4 | |||
| 7522f396e7 | |||
| 9b2f083e4b | |||
| 9a949d7391 | |||
| 13904fb702 | |||
| f7160e6dec | |||
| 1983691950 | |||
| 6866644d0c | |||
| b120aabaa3 | |||
| 1d442c1d7a | |||
| 9a0294360c | |||
| 9de0c5b051 | |||
| 9e9b86f1b4 | |||
| 8ff5ec486f | |||
| 5576339bcc | |||
| e668e80f1c | |||
| 3aaa6e916b | |||
| ad329eddb9 | |||
| 990e804bd3 | |||
| 299e26bebe | |||
| 96b6d7d81f | |||
| 27d5c3400c | |||
| bb9c0c9b68 | |||
| 483190e7c1 | |||
| c89ff8b551 | |||
| 63232e56a3 | |||
| 643504d89b | |||
| 2288f9d2fc | |||
| 804c2586a9 | |||
| fedd559fd6 | |||
| 5f07bdaaf1 | |||
| fb643a1ade | |||
| 069fd99341 | |||
| 3dc760484a | |||
| 13580ea5fb | |||
| aa5dd1abc6 | |||
| de444bf770 | |||
| 821f71ac28 | |||
| faebc62917 | |||
| 41e572445c | |||
| 66f5ea50c5 | |||
| ce4b8088ec | |||
| b1138eb9db | |||
| 8412f303dd | |||
| ba87a7f876 | |||
| 9f1b0554d6 | |||
| 3dd15499e6 | |||
| 393e99201a | |||
| 153b7f64b7 | |||
| 7b2d45665c | |||
| 37873dd938 |
+23
-1
@@ -5,6 +5,28 @@ client/dist
|
||||
data
|
||||
uploads
|
||||
.git
|
||||
.env
|
||||
.github
|
||||
**/.env
|
||||
**/.env.*
|
||||
*.log
|
||||
*.md
|
||||
!client/**/*.md
|
||||
chart/
|
||||
docs/
|
||||
docker-compose.yml
|
||||
unraid-template.xml
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
**/coverage
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode
|
||||
.idea
|
||||
sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Normalize line endings to LF on commit
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicitly enforce LF for source files
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve TREK
|
||||
title: "[BUG] "
|
||||
labels: []
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Pre-flight checklist
|
||||
options:
|
||||
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: TREK version
|
||||
description: Found in the Settings → About, or in the Docker image tag
|
||||
placeholder: "e.g. 2.8.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: When I do X, Y happens instead of Z…
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Step-by-step instructions to reliably trigger the bug.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment method
|
||||
options:
|
||||
- Docker Compose
|
||||
- Docker (standalone)
|
||||
- Kubernetes / Helm
|
||||
- Unraid template
|
||||
- Sources
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Host OS
|
||||
placeholder: "e.g. Ubuntu 24.04, Unraid 6.12, Synology DSM 7"
|
||||
|
||||
- type: dropdown
|
||||
id: user_os
|
||||
attributes:
|
||||
label: Accessing TREK from
|
||||
options:
|
||||
- Desktop browser
|
||||
- Mobile browser
|
||||
- Mobile app (PWA)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser (if applicable)
|
||||
placeholder: "e.g. Chrome 124, Firefox 125, Safari 17"
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs or error output
|
||||
description: Paste any relevant server or browser console output here.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: Drag and drop screenshots here if applicable.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Anything else that might help us understand the issue.
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/mauriceboe/TREK/wiki
|
||||
about: Check the docs before opening an issue
|
||||
- name: Feature Request
|
||||
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
|
||||
about: Suggest a new feature or improvement in Discussions
|
||||
- name: Questions & Help
|
||||
url: https://github.com/mauriceboe/TREK/discussions
|
||||
about: For questions and general help, use Discussions instead
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Close untitled issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close if title is empty or generic
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
const badTitles = [
|
||||
"[bug]",
|
||||
"bug report",
|
||||
"bug",
|
||||
"issue",
|
||||
];
|
||||
|
||||
const featureRequestTitles = [
|
||||
"feature request",
|
||||
"[feature]",
|
||||
"[feature request]",
|
||||
"[enhancement]"
|
||||
]
|
||||
|
||||
const titleLower = title.toLowerCase();
|
||||
|
||||
if (badTitles.includes(titleLower)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
});
|
||||
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
});
|
||||
}
|
||||
@@ -3,11 +3,72 @@ name: Build & Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
run: |
|
||||
# Check if this push is a merge commit from dev branch
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s)
|
||||
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
|
||||
|
||||
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
|
||||
BUMP="minor"
|
||||
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
|
||||
BUMP="minor"
|
||||
else
|
||||
BUMP="patch"
|
||||
fi
|
||||
|
||||
echo "Bump type: $BUMP"
|
||||
|
||||
# Read current version
|
||||
CURRENT=$(node -p "require('./server/package.json').version")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
|
||||
if [ "$BUMP" = "minor" ]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$CURRENT → $NEW_VERSION ($BUMP)"
|
||||
|
||||
# Update both package.json files
|
||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
needs: version-bump
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -21,6 +82,8 @@ jobs:
|
||||
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -54,13 +117,11 @@ jobs:
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [version-bump, build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download build digests
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -79,12 +140,13 @@ jobs:
|
||||
- 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:${{ steps.version.outputs.VERSION }} \
|
||||
-t mauriceboe/trek:$VERSION \
|
||||
-t mauriceboe/nomad:latest \
|
||||
-t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \
|
||||
-t mauriceboe/nomad:$VERSION \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd server && npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
@@ -11,6 +11,9 @@ client/public/icons/*.png
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# User data
|
||||
server/data/
|
||||
@@ -28,6 +31,7 @@ Thumbs.db
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -52,3 +56,6 @@ coverage
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
test-data
|
||||
@@ -0,0 +1,57 @@
|
||||
# Contributing to TREK
|
||||
|
||||
Thanks for your interest in contributing! Please read these guidelines before opening a pull request.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
|
||||
## Pull Requests
|
||||
|
||||
### Your PR should include:
|
||||
|
||||
- **Summary** — What does this change and why? (1-3 bullet points)
|
||||
- **Test plan** — How did you verify it works?
|
||||
- **Linked issue** — Reference the issue (e.g. `Fixes #123`)
|
||||
|
||||
### Your PR will be closed if it:
|
||||
|
||||
- Wasn't discussed and approved in `#github-pr` on Discord first
|
||||
- Introduces breaking changes
|
||||
- Adds unnecessary complexity or features beyond scope
|
||||
- Reformats or refactors unrelated code
|
||||
- Adds dependencies without clear justification
|
||||
|
||||
### Commit messages
|
||||
|
||||
Use [conventional commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
fix(maps): correct zoom level on Safari
|
||||
feat(budget): add CSV export for expenses
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
|
||||
# Server
|
||||
cd server && npm install && npm run dev
|
||||
|
||||
# Client (separate terminal)
|
||||
cd client && npm install && npm run dev
|
||||
```
|
||||
|
||||
Server: `http://localhost:3001` | Client: `http://localhost:5173`
|
||||
|
||||
On first run, check the server logs for the auto-generated admin credentials.
|
||||
|
||||
## More Details
|
||||
|
||||
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
+12
-17
@@ -1,4 +1,4 @@
|
||||
# Stage 1: React Client bauen
|
||||
# Stage 1: Build React client
|
||||
FROM node:22-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
@@ -6,37 +6,32 @@ RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Produktions-Server
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
|
||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache tzdata python3 make g++ && \
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
|
||||
# Server-Code kopieren
|
||||
COPY server/ ./
|
||||
|
||||
# Gebauten Client kopieren
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
|
||||
# Fonts für PDF-Export kopieren
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
|
||||
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
|
||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app
|
||||
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
# Umgebung setzen
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "--import", "tsx", "src/index.ts"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# MCP Integration
|
||||
|
||||
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
|
||||
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
|
||||
structured API.
|
||||
|
||||
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
- [Example](#example)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Enable the MCP addon (admin)
|
||||
|
||||
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
|
||||
|
||||
### 2. Create an API token
|
||||
|
||||
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
|
||||
|
||||
1. Click **Create New Token**
|
||||
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
|
||||
3. **Copy the token immediately** — it is shown only once and cannot be recovered
|
||||
|
||||
Each user can create up to **10 tokens**.
|
||||
|
||||
### 3. Configure your MCP client
|
||||
|
||||
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
|
||||
`claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer trek_your_token_here"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
---
|
||||
|
||||
## Limitations & Important Notes
|
||||
|
||||
| Limitation | Details |
|
||||
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
|
||||
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
|
||||
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
|
||||
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
|
||||
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
|
||||
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. |
|
||||
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
|
||||
| **Token limits** | Maximum 10 API tokens per user. |
|
||||
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
|
||||
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||
|
||||
---
|
||||
|
||||
## Resources (read-only)
|
||||
|
||||
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
|
||||
making changes.
|
||||
|
||||
| Resource | URI | Description |
|
||||
|-------------------|--------------------------------------------|-----------------------------------------------------------|
|
||||
| Trips | `trek://trips` | All trips you own or are a member of |
|
||||
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
|
||||
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
|
||||
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip |
|
||||
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
|
||||
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
|
||||
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
|
||||
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
|
||||
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
|
||||
|
||||
---
|
||||
|
||||
## Tools (read-write)
|
||||
|
||||
TREK exposes **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything
|
||||
about a trip in a single call.
|
||||
|
||||
### Trip Summary
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as your context loader. |
|
||||
|
||||
### Trips
|
||||
|
||||
| Tool | Description |
|
||||
|---------------|---------------------------------------------------------------------------------------------|
|
||||
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
|
||||
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
|
||||
| `update_trip` | Update a trip's title, description, dates, or currency. |
|
||||
| `delete_trip` | Delete a trip. **Owner only.** |
|
||||
|
||||
### Places
|
||||
|
||||
| Tool | Description |
|
||||
|----------------|-----------------------------------------------------------------------------------|
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. |
|
||||
| `update_place` | Update any field of an existing place. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
|
||||
### Day Planning
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|-------------------------------------------------------------------------------|
|
||||
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
|
||||
| `unassign_place` | Remove a place assignment from a day. |
|
||||
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
|
||||
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). |
|
||||
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
|
||||
|
||||
### Reservations
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
|
||||
### Budget
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|--------------------------------------------------------------|
|
||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
|
||||
| `delete_budget_item` | Remove a budget item. |
|
||||
|
||||
### Packing
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|--------------------------------------------------------------|
|
||||
| `create_packing_item` | Add an item to the packing checklist with optional category. |
|
||||
| `update_packing_item` | Rename an item or change its category. |
|
||||
| `toggle_packing_item` | Check or uncheck a packing item. |
|
||||
| `delete_packing_item` | Remove a packing item. |
|
||||
|
||||
### Day Notes
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|-----------------------------------------------------------------------|
|
||||
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
|
||||
| `update_day_note` | Edit a day note's text, time, or icon. |
|
||||
| `delete_day_note` | Remove a note from a day. |
|
||||
|
||||
### Collab Notes
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
|
||||
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
|
||||
| `delete_collab_note` | Delete a collab note and its associated files. |
|
||||
|
||||
### Bucket List
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||
|
||||
### Atlas
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|--------------------------------------------------------------------------------|
|
||||
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
|
||||
| `unmark_country_visited` | Remove a country from your visited list. |
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
|
||||
|
||||
Initial prompt (1st message):
|
||||
|
||||
```
|
||||
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
|
||||
and leaving April 11 2027. It's cherry blossom season so please keep that
|
||||
in mind when picking spots.
|
||||
|
||||
Before writing anything to TREK, do some research: look up what's worth
|
||||
visiting, figure out a logical day-by-day flow (group nearby spots together
|
||||
to avoid unnecessary travel), find a well-reviewed hotel in a central
|
||||
neighbourhood, and think about what kind of food and restaurant experiences
|
||||
are worth including.
|
||||
|
||||
Once you have a solid plan, write the whole thing to TREK:
|
||||
- Create the trip
|
||||
- Add all the places you've researched with their real coordinates
|
||||
- Build out the daily itinerary with sensible visiting times
|
||||
- Book the hotel as a reservation and link it properly to the accommodation days
|
||||
- Add any notable restaurant reservations
|
||||
- Put together a realistic budget in EUR
|
||||
- Build a packing list suited to April in Kyoto
|
||||
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
|
||||
- Add a day note for each day with any important heads-up (early start, crowd
|
||||
tips, booking requirements, etc.)
|
||||
- Mark Japan as visited in my Atlas
|
||||
|
||||
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
|
||||
of everything that was added.
|
||||
```
|
||||
|
||||
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
|
||||
|
||||

|
||||
@@ -9,6 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
||||
@@ -98,7 +99,9 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
@@ -120,20 +123,54 @@ services:
|
||||
app:
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
# - OIDC_ISSUER=https://auth.example.com
|
||||
# - OIDC_CLIENT_ID=trek
|
||||
# - OIDC_CLIENT_SECRET=supersecret
|
||||
# - OIDC_DISPLAY_NAME="SSO"
|
||||
# - OIDC_ONLY=true # disable password auth entirely
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
|
||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
||||
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
|
||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only)
|
||||
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
||||
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
|
||||
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
|
||||
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -162,6 +199,18 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
|
||||
### Rotating the Encryption Key
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
|
||||
|
||||
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||
@@ -226,17 +275,34 @@ trek.yourdomain.com {
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| **Core** | | |
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment | `production` |
|
||||
| `JWT_SECRET` | JWT signing secret | Auto-generated |
|
||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` |
|
||||
| `OIDC_ISSUER` | OIDC provider URL | — |
|
||||
| `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` | SSO button label | `SSO` |
|
||||
| `OIDC_ONLY` | Disable password auth | `false` |
|
||||
| `TRUST_PROXY` | Trust proxy headers | `1` |
|
||||
| `DEMO_MODE` | Enable demo mode | `false` |
|
||||
| `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
|
||||
|
||||
@@ -261,6 +327,7 @@ docker build -t trek .
|
||||
|
||||
- **Database**: SQLite, stored in `./data/travel.db`
|
||||
- **Uploads**: Stored in `./uploads/`
|
||||
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups**: Create and restore via Admin Panel
|
||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||
|
||||
|
||||
+5
-2
@@ -14,7 +14,6 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
--set secretEnv.JWT_SECRET=your_jwt_secret \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=yourdomain.com
|
||||
```
|
||||
@@ -29,5 +28,9 @@ See `values.yaml` for more options.
|
||||
## 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 must be set for production use.
|
||||
- `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.
|
||||
|
||||
+20
-10
@@ -1,13 +1,23 @@
|
||||
1. JWT_SECRET handling:
|
||||
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
|
||||
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
|
||||
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. Example usage:
|
||||
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
|
||||
- Generate a random secret: `--set generateJwtSecret=true`
|
||||
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 in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
|
||||
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
|
||||
|
||||
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
|
||||
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,57 @@ metadata:
|
||||
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 }}
|
||||
|
||||
@@ -11,6 +11,9 @@ spec:
|
||||
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:
|
||||
@@ -20,21 +23,46 @@ spec:
|
||||
- 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: JWT_SECRET
|
||||
- name: ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
|
||||
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
|
||||
|
||||
@@ -10,6 +10,9 @@ metadata:
|
||||
{{- 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 }}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{- if .Values.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -23,3 +24,4 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
|
||||
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@@ -7,17 +7,41 @@ metadata:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
|
||||
{{ .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.generateJwtSecret) }}
|
||||
{{- 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: {{ include "trek.fullname" . }}-secret
|
||||
name: {{ $secretName }}
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
|
||||
{{- 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 }}
|
||||
|
||||
+65
-9
@@ -15,21 +15,70 @@ service:
|
||||
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.
|
||||
# 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.
|
||||
|
||||
|
||||
# JWT secret configuration
|
||||
# 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:
|
||||
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
|
||||
JWT_SECRET: ""
|
||||
# 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 JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
|
||||
generateJwtSecret: false
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
# If set, use an existing Kubernetes secret for JWT_SECRET
|
||||
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
|
||||
existingSecret: ""
|
||||
existingSecretKey: JWT_SECRET
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
@@ -38,10 +87,17 @@ persistence:
|
||||
uploads:
|
||||
size: 1Gi
|
||||
|
||||
resources: {}
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.4",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
@@ -2789,9 +2789,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3693,9 +3693,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4679,9 +4679,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5941,9 +5941,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7181,9 +7181,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8705,9 +8705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.7.1",
|
||||
"version": "2.8.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
+48
-8
@@ -3,7 +3,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
@@ -12,10 +11,12 @@ import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import DemoBanner from './components/Layout/DemoBanner'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -23,8 +24,12 @@ interface ProtectedRouteProps {
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -38,7 +43,17 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
|
||||
if (
|
||||
appRequireMfa &&
|
||||
user &&
|
||||
!user.mfa_enabled &&
|
||||
location.pathname !== '/settings'
|
||||
) {
|
||||
return <Navigate to="/settings?mfa=required" replace />
|
||||
}
|
||||
|
||||
if (adminRequired && user && user.role !== 'admin') {
|
||||
@@ -63,17 +78,21 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
if (!location.pathname.startsWith('/shared/')) {
|
||||
loadUser()
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
@@ -99,13 +118,26 @@ export default function App() {
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useInAppNotificationListener()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
useEffect(() => {
|
||||
// Shared page always forces light mode
|
||||
if (isSharedPage) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', '#ffffff')
|
||||
return
|
||||
}
|
||||
|
||||
const mode = settings.dark_mode
|
||||
const applyDark = (isDark: boolean) => {
|
||||
document.documentElement.classList.toggle('dark', isDark)
|
||||
@@ -121,7 +153,7 @@ export default function App() {
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode])
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -187,6 +219,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<InAppNotificationsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</TranslationProvider>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export async function getAuthUrl(url: string, purpose: 'download'): Promise<string> {
|
||||
if (!url) return url
|
||||
try {
|
||||
const resp = await fetch('/api/auth/resource-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ purpose }),
|
||||
})
|
||||
if (!resp.ok) return url
|
||||
const { token } = await resp.json()
|
||||
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
|
||||
|
||||
const MAX_CONCURRENT = 6
|
||||
let active = 0
|
||||
const queue: Array<() => void> = []
|
||||
|
||||
function dequeue() {
|
||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||
active++
|
||||
queue.shift()!()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearImageQueue() {
|
||||
queue.length = 0
|
||||
}
|
||||
|
||||
export async function fetchImageAsBlob(url: string): Promise<string> {
|
||||
if (!url) return ''
|
||||
return new Promise<string>((resolve) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) { resolve(''); return }
|
||||
const blob = await resp.blob()
|
||||
resolve(URL.createObjectURL(blob))
|
||||
} catch {
|
||||
resolve('')
|
||||
} finally {
|
||||
active--
|
||||
dequeue()
|
||||
}
|
||||
}
|
||||
if (active < MAX_CONCURRENT) {
|
||||
active++
|
||||
run()
|
||||
} else {
|
||||
queue.push(run)
|
||||
}
|
||||
})
|
||||
}
|
||||
+65
-14
@@ -3,18 +3,15 @@ import { getSocketId } from './websocket'
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token and socket ID
|
||||
// Request interceptor - add socket ID
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
@@ -28,12 +25,19 @@ apiClient.interceptors.request.use(
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth_token')
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
||||
window.location.href = '/login'
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -44,7 +48,7 @@ export const authApi = {
|
||||
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),
|
||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
@@ -61,6 +65,11 @@ export const authApi = {
|
||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
mcpTokens: {
|
||||
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export const tripsApi = {
|
||||
@@ -75,6 +84,7 @@ export const tripsApi = {
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
@@ -95,6 +105,8 @@ export const placesApi = {
|
||||
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 = {
|
||||
@@ -119,12 +131,24 @@ export const packingApi = {
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const todoApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: () => apiClient.get('/tags').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
|
||||
@@ -151,7 +175,6 @@ export const adminApi = {
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
@@ -170,6 +193,15 @@ export const adminApi = {
|
||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
|
||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -267,9 +299,8 @@ export const backupApi = {
|
||||
list: () => apiClient.get('/backup/list').then(r => r.data),
|
||||
create: () => apiClient.post('/backup/create').then(r => r.data),
|
||||
download: async (filename: string): Promise<void> => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const res = await fetch(`/api/backup/download/${filename}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
@@ -300,8 +331,28 @@ export const shareApi = {
|
||||
|
||||
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),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
markAllRead: () =>
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
|
||||
+42
-12
@@ -9,9 +9,10 @@ let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set<WebSocketListener>()
|
||||
const activeTrips = new Set<string>()
|
||||
let currentToken: string | null = null
|
||||
let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
let connecting = false
|
||||
|
||||
export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
@@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
function getWsUrl(token: string): string {
|
||||
function getWsUrl(wsToken: string): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${token}`
|
||||
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||
}
|
||||
|
||||
async function fetchWsToken(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/ws-token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (resp.status === 401) {
|
||||
// Session expired — stop reconnecting
|
||||
shouldReconnect = false
|
||||
return null
|
||||
}
|
||||
if (!resp.ok) return null
|
||||
const { token } = await resp.json()
|
||||
return token as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent): void {
|
||||
@@ -45,19 +65,29 @@ function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (currentToken) {
|
||||
connectInternal(currentToken, true)
|
||||
if (shouldReconnect) {
|
||||
connectInternal(true)
|
||||
}
|
||||
}, reconnectDelay)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
function connectInternal(token: string, _isReconnect = false): void {
|
||||
async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
if (connecting) return
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = getWsUrl(token)
|
||||
connecting = true
|
||||
const wsToken = await fetchWsToken()
|
||||
connecting = false
|
||||
|
||||
if (!wsToken) {
|
||||
if (shouldReconnect) scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
const url = getWsUrl(wsToken)
|
||||
socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
@@ -82,7 +112,7 @@ function connectInternal(token: string, _isReconnect = false): void {
|
||||
|
||||
socket.onclose = () => {
|
||||
socket = null
|
||||
if (currentToken) {
|
||||
if (shouldReconnect) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
@@ -92,18 +122,18 @@ function connectInternal(token: string, _isReconnect = false): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(token: string): void {
|
||||
currentToken = token
|
||||
export function connect(): void {
|
||||
shouldReconnect = true
|
||||
reconnectDelay = 1000
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
connectInternal(token, false)
|
||||
connectInternal(false)
|
||||
}
|
||||
|
||||
export function disconnect(): void {
|
||||
currentToken = null
|
||||
shouldReconnect = false
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -14,7 +15,17 @@ interface Addon {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
toggle: () => Promise<void>
|
||||
}
|
||||
|
||||
interface AddonIconProps {
|
||||
@@ -32,7 +43,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const toast = useToast()
|
||||
const [addons, setAddons] = useState([])
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState<Addon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,13 +63,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (addon) => {
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled
|
||||
// Optimistic update
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
try {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
window.dispatchEvent(new Event('addons-changed'))
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
@@ -66,8 +78,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'photo_provider'
|
||||
}
|
||||
|
||||
const isPhotosAddon = (addon: Addon) => {
|
||||
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
|
||||
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
}
|
||||
|
||||
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||
const enableProvider = !providerAddon.enabled
|
||||
const prev = addons
|
||||
|
||||
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||
|
||||
try {
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch {
|
||||
setAddons(prev)
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
const photosAddon = tripAddons.find(isPhotosAddon)
|
||||
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
}))
|
||||
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -105,7 +153,42 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
<AddonRow
|
||||
addon={addon}
|
||||
onToggle={handleToggle}
|
||||
t={t}
|
||||
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
|
||||
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
|
||||
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
|
||||
hideToggle={photosAddon && addon.id === photosAddon.id}
|
||||
/>
|
||||
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -144,6 +227,21 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
))}
|
||||
</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>
|
||||
@@ -153,8 +251,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addonId: string) => void
|
||||
onToggle: (addon: Addon) => void
|
||||
t: (key: string) => string
|
||||
statusOverride?: boolean
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
@@ -169,9 +269,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
const displayName = nameOverride || label.name
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
@@ -182,41 +285,40 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="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')}
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{!hideToggle && (
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface AdminMcpToken {
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
user_id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
adminApi.mcpTokens()
|
||||
.then(d => setTokens(d.tokens || []))
|
||||
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deleteMcpToken(id)
|
||||
setTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setDeleteConfirmId(null)
|
||||
toast.success(t('admin.mcpTokens.deleteSuccess'))
|
||||
} catch {
|
||||
toast.error(t('admin.mcpTokens.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,11 @@ interface AuditEntry {
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
export default function AuditLogPanel(): React.ReactElement {
|
||||
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)
|
||||
@@ -66,9 +70,10 @@ export default function AuditLogPanel(): React.ReactElement {
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(locale, {
|
||||
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: serverTimezone || undefined,
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
|
||||
@@ -324,9 +324,11 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import {
|
||||
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
interface AppUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function DevNotificationsPanel(): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const [sending, setSending] = useState<string | null>(null)
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
|
||||
const [users, setUsers] = useState<AppUser[]>([])
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
tripsApi.list().then(data => {
|
||||
const list = (data.trips || data || []) as Trip[]
|
||||
setTrips(list)
|
||||
if (list.length > 0) setSelectedTripId(list[0].id)
|
||||
}).catch(() => {})
|
||||
adminApi.users().then(data => {
|
||||
const list = (data.users || data || []) as AppUser[]
|
||||
setUsers(list)
|
||||
if (list.length > 0) setSelectedUserId(list[0].id)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||
setSending(label)
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
toast.success(`Sent: ${label}`)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed')
|
||||
} finally {
|
||||
setSending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTrip = trips.find(t => t.id === selectedTripId)
|
||||
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||
const username = user?.username || 'Admin'
|
||||
const tripTitle = selectedTrip?.title || 'Test Trip'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id, label, sub, icon: Icon, color, onClick,
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}20`, color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
const UserSelector = () => (
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||
DEV ONLY
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Notification Testing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||
onClick={() => fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||
onClick={() => fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||
onClick={() => fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||
onClick={() => fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||
onClick={() => selectedTripId && fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||
onClick={() => selectedTripId && fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||
onClick={() => selectedTripId && fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
color="#06b6d4"
|
||||
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
|
||||
})}
|
||||
/>
|
||||
<Btn
|
||||
id={`vacay_invite-${selectedUserId}`}
|
||||
label="vacay_invite"
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
color="#f97316"
|
||||
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, planId: '1' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||
onClick={() => fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
const REPO = 'mauriceboe/NOMAD'
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
export default function GitHubPanel() {
|
||||
@@ -72,11 +72,15 @@ export default function GitHubPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const inlineFormat = (text) => {
|
||||
return text
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -115,7 +119,7 @@ export default function GitHubPanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
@@ -152,6 +156,81 @@ export default function GitHubPanel() {
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nSdKaXgN"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
interface PermissionEntry {
|
||||
key: string
|
||||
level: PermissionLevel
|
||||
defaultLevel: PermissionLevel
|
||||
allowedLevels: PermissionLevel[]
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
admin: 'perm.level.admin',
|
||||
trip_owner: 'perm.level.tripOwner',
|
||||
trip_member: 'perm.level.tripMember',
|
||||
everybody: 'perm.level.everybody',
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
|
||||
{ id: 'members', keys: ['member_manage'] },
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
]
|
||||
|
||||
export default function PermissionsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [entries, setEntries] = useState<PermissionEntry[]>([])
|
||||
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions()
|
||||
}, [])
|
||||
|
||||
const loadPermissions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getPermissions()
|
||||
setEntries(data.permissions)
|
||||
const vals: Record<string, PermissionLevel> = {}
|
||||
for (const p of data.permissions) vals[p.key] = p.level
|
||||
setValues(vals)
|
||||
setDirty(false)
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (key: string, level: PermissionLevel) => {
|
||||
setValues(prev => ({ ...prev, [key]: level }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await adminApi.updatePermissions(values)
|
||||
if (data.permissions) {
|
||||
usePermissionsStore.getState().setPermissions(data.permissions)
|
||||
}
|
||||
setDirty(false)
|
||||
toast.success(t('perm.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults: Record<string, PermissionLevel> = {}
|
||||
for (const p of entries) defaults[p.key] = p.defaultLevel
|
||||
setValues(defaults)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('perm.resetDefaults')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{CATEGORIES.map(cat => (
|
||||
<div key={cat.id} className="px-6 py-4">
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{t(`perm.cat.${cat.id}`)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{cat.keys.map(key => {
|
||||
const entry = entryMap.get(key)
|
||||
if (!entry) return null
|
||||
const currentLevel = values[key] || entry.defaultLevel
|
||||
const isDefault = currentLevel === entry.defaultLevel
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDefault && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
{t('perm.customized')}
|
||||
</span>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={currentLevel}
|
||||
onChange={(val) => handleChange(key, val as PermissionLevel)}
|
||||
options={entry.allowedLevels.map(l => ({
|
||||
value: l,
|
||||
label: t(LEVEL_LABELS[l] || l),
|
||||
}))}
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
|
||||
@@ -59,7 +61,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
|
||||
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
|
||||
|
||||
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
|
||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
|
||||
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value ?? '')
|
||||
const inputRef = useRef(null)
|
||||
@@ -86,12 +88,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
: (value || '')
|
||||
|
||||
return (
|
||||
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
||||
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
)
|
||||
@@ -99,7 +101,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
|
||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -109,12 +111,13 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [persons, setPersons] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [expenseDate, setExpenseDate] = useState('')
|
||||
const nameRef = useRef(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
@@ -132,15 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
@@ -227,9 +235,10 @@ interface BudgetMemberChipsProps {
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||
compact?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
@@ -271,17 +280,19 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTog
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
)}
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
@@ -412,12 +423,14 @@ interface BudgetPanelProps {
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
const canEdit = can('budget_edit', trip)
|
||||
|
||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
@@ -470,6 +483,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
setNewCategoryName('')
|
||||
}
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||
const d = currencyDecimals(currency)
|
||||
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
|
||||
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
|
||||
for (const cat of categoryNames) {
|
||||
for (const item of (grouped[cat] || [])) {
|
||||
const pp = calcPP(item.total_price, item.persons)
|
||||
const pd = calcPD(item.total_price, item.days)
|
||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||
rows.push([
|
||||
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
|
||||
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
|
||||
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
|
||||
esc(item.note || ''),
|
||||
].join(sep))
|
||||
}
|
||||
}
|
||||
|
||||
const bom = '\uFEFF'
|
||||
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim()
|
||||
a.download = `budget-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||
|
||||
@@ -482,16 +530,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -504,6 +554,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<Calculator size={20} color="var(--text-primary)" />
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
||||
</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Download size={13} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
@@ -518,7 +572,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
{editingCat?.name === cat ? (
|
||||
{canEdit && editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
@@ -530,21 +584,25 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -552,14 +610,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||
<th style={{ ...th, width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -574,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
|
||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
||||
{hasMultipleMembers && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
@@ -584,12 +643,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
compact={false}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
@@ -598,29 +658,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||
{canEdit ? (
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -629,29 +701,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
disabled={!canEdit}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||
@@ -666,7 +741,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'
|
||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
@@ -353,6 +355,9 @@ interface CollabChatProps {
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(msg.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onTouchEnd={e => {
|
||||
const now = Date.now()
|
||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||
if (now - lastTap < 300) {
|
||||
if (now - lastTap < 300 && canEdit) {
|
||||
e.preventDefault()
|
||||
const touch = e.changedTouches?.[0]
|
||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||
@@ -692,7 +697,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -703,8 +708,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && (
|
||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
disabled={!canEdit}
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||
maxHeight: 100, overflowY: 'hidden',
|
||||
opacity: canEdit ? 1 : 0.5,
|
||||
}}
|
||||
placeholder={t('collab.chat.placeholder')}
|
||||
value={text}
|
||||
@@ -805,15 +814,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
|
||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
@@ -94,22 +97,37 @@ interface FilePreviewPortalProps {
|
||||
}
|
||||
|
||||
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const [authUrl, setAuthUrl] = useState('')
|
||||
const rawUrl = file?.url || ''
|
||||
useEffect(() => {
|
||||
setAuthUrl('')
|
||||
if (!rawUrl) return
|
||||
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
|
||||
}, [rawUrl])
|
||||
|
||||
if (!file) return null
|
||||
const url = file.url || `/uploads/${file.filename}`
|
||||
const isImage = file.mime_type?.startsWith('image/')
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
const isTxt = file.mime_type?.startsWith('text/')
|
||||
|
||||
const openInNewTab = async () => {
|
||||
const u = await getAuthUrl(rawUrl, 'download')
|
||||
window.open(u, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
{authUrl
|
||||
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
: <Loader2 size={32} className="animate-spin" style={{ color: 'rgba(255,255,255,0.5)' }} />
|
||||
}
|
||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,19 +138,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={`${url}#view=FitH`} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,6 +160,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||
}
|
||||
|
||||
const NOTE_COLORS = [
|
||||
{ value: '#6366f1', label: 'Indigo' },
|
||||
{ value: '#ef4444', label: 'Red' },
|
||||
@@ -216,7 +242,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
||||
interface NoteFormModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
||||
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
|
||||
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||
existingCategories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
getCategoryColor: (category: string) => string
|
||||
@@ -226,6 +252,9 @@ interface NoteFormModalProps {
|
||||
}
|
||||
|
||||
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canUploadFiles = can('file_upload', tripObj)
|
||||
const isEdit = !!note
|
||||
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
||||
|
||||
@@ -298,6 +327,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPaste={e => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -450,18 +480,18 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
</div>
|
||||
|
||||
{/* File attachments */}
|
||||
<div>
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
|
||||
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Existing attachments (edit mode) */}
|
||||
{existingAttachments.map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
return (
|
||||
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{isImage && <img src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
|
||||
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
|
||||
<X size={10} />
|
||||
@@ -478,12 +508,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label htmlFor="note-file-input"
|
||||
<button type="button" onClick={() => fileRef.current?.click()}
|
||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
@@ -689,6 +719,7 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
|
||||
interface NoteCardProps {
|
||||
note: CollabNote
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => Promise<void>
|
||||
onEdit: (note: CollabNote) => void
|
||||
@@ -699,7 +730,7 @@ interface NoteCardProps {
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||
@@ -760,24 +791,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
||||
<Maximize2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||
</button>
|
||||
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
</button>}
|
||||
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</button>}
|
||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||
{/* Author avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||
@@ -838,7 +869,7 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return isImage ? (
|
||||
<img key={a.id} src={a.url} alt={a.original_name}
|
||||
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => onPreviewFile?.(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
@@ -879,6 +910,9 @@ interface CollabNotesProps {
|
||||
|
||||
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
const [notes, setNotes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
@@ -964,7 +998,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {}
|
||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
|
||||
}
|
||||
// Reload note with attachments
|
||||
const fresh = await collabApi.getNotes(tripId)
|
||||
@@ -1124,17 +1158,17 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
<button onClick={() => setShowNewModal(true)}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => setShowNewModal(true)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
<Plus size={12} />
|
||||
{t('collab.notes.new')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1252,6 +1286,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
key={note.id}
|
||||
note={note}
|
||||
currentUser={currentUser}
|
||||
canEdit={canEdit}
|
||||
onUpdate={handleUpdateNote}
|
||||
onDelete={handleDeleteNote}
|
||||
onEdit={setEditingNote}
|
||||
@@ -1303,12 +1338,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</button>}
|
||||
<button onClick={() => setViewingNote(null)}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
@@ -1319,6 +1354,41 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(viewingNote.attachments || []).map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return (
|
||||
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
|
||||
{isImage ? (
|
||||
<AuthedImg src={a.url} alt={a.original_name}
|
||||
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => setPreviewFile(a)}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<div title={a.original_name} onClick={() => setPreviewFile(a)}
|
||||
style={{
|
||||
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -1327,6 +1397,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
|
||||
{showNewModal && (
|
||||
<NoteFormModal
|
||||
note={null}
|
||||
tripId={tripId}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
onSubmit={handleCreateNote}
|
||||
existingCategories={categories}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import ReactDOM from 'react-dom'
|
||||
import type { User } from '../../types'
|
||||
|
||||
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
interface PollCardProps {
|
||||
poll: Poll
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||
onClose: (pollId: number) => Promise<void>
|
||||
onDelete: (pollId: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
const total = totalVotes(poll)
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -337,6 +342,9 @@ interface CollabPollsProps {
|
||||
|
||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
const [polls, setPolls] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activePolls.length > 0 && activePolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
</div>
|
||||
)}
|
||||
{closedPolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) {
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
|
||||
@@ -4,11 +4,17 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
const CURRENCIES = [
|
||||
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
|
||||
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
|
||||
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
|
||||
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
|
||||
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
|
||||
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||
]
|
||||
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
@@ -33,41 +37,130 @@ function formatDateWithLocale(dateStr, locale) {
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
// Image lightbox
|
||||
// Image lightbox with gallery navigation
|
||||
interface ImageLightboxProps {
|
||||
file: TripFile & { url: string }
|
||||
files: (TripFile & { url: string })[]
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||
function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(initialIndex)
|
||||
const [imgSrc, setImgSrc] = useState('')
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const file = files[index]
|
||||
|
||||
useEffect(() => {
|
||||
setImgSrc('')
|
||||
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||
}, [file?.url])
|
||||
|
||||
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
||||
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') goPrev()
|
||||
if (e.key === 'ArrowRight') goNext()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
if (!file) return null
|
||||
|
||||
const hasPrev = index > 0
|
||||
const hasNext = index < files.length - 1
|
||||
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
|
||||
<button onClick={e => { e.stopPropagation(); onClick() }}
|
||||
style={{
|
||||
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
|
||||
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
|
||||
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
|
||||
onClick={onClose}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
if (touchStart === null) return
|
||||
const diff = e.changedTouches[0].clientX - touchStart
|
||||
if (diff > 60) goPrev()
|
||||
else if (diff < -60) goNext()
|
||||
setTouchStart(null)
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.original_name}
|
||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
{file.original_name}
|
||||
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main image + nav */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
{navBtn('left', goPrev, hasPrev)}
|
||||
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
||||
{navBtn('right', goNext, hasNext)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{files.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
{files.map((f, i) => (
|
||||
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
|
||||
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated image — fetches a short-lived download token and renders the image
|
||||
function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
|
||||
}
|
||||
|
||||
// Source badge
|
||||
interface SourceBadgeProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
@@ -148,11 +241,13 @@ interface FileManagerProps {
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [lightboxFile, setLightboxFile] = useState(null)
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||
const [showTrash, setShowTrash] = useState(false)
|
||||
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
||||
const [loadingTrash, setLoadingTrash] = useState(false)
|
||||
const toast = useToast()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const loadTrash = useCallback(async () => {
|
||||
@@ -247,6 +342,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
})
|
||||
|
||||
const handlePaste = useCallback((e) => {
|
||||
if (!can('file_upload', trip)) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const pastedFiles = []
|
||||
@@ -281,6 +377,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
|
||||
const [previewFile, setPreviewFile] = useState(null)
|
||||
const [previewFileUrl, setPreviewFileUrl] = useState('')
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
|
||||
} else {
|
||||
setPreviewFileUrl('')
|
||||
}
|
||||
}, [previewFile?.url])
|
||||
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||
|
||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||
@@ -292,9 +396,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
|
||||
|
||||
const openFile = (file) => {
|
||||
if (isImage(file.mime_type)) {
|
||||
setLightboxFile(file)
|
||||
const idx = imageFiles.findIndex(f => f.id === file.id)
|
||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
}
|
||||
@@ -311,8 +418,6 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
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)
|
||||
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,
|
||||
@@ -326,7 +431,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
>
|
||||
{/* Icon or thumbnail */}
|
||||
<div
|
||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -334,7 +439,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}}
|
||||
>
|
||||
{isImage(file.mime_type)
|
||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: (() => {
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
@@ -355,7 +460,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
)}
|
||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||
<span
|
||||
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||
>
|
||||
{file.original_name}
|
||||
@@ -386,14 +491,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{isTrash ? (
|
||||
<>
|
||||
<button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
</button>}
|
||||
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -401,18 +506,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||
</button>
|
||||
<button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
</button>}
|
||||
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -423,7 +528,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
||||
{/* Lightbox */}
|
||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
||||
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||
|
||||
{/* Assign modal */}
|
||||
{assignFileId && ReactDOM.createPortal(
|
||||
@@ -622,12 +727,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||
<button
|
||||
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</a>
|
||||
</button>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
@@ -637,13 +743,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
</div>
|
||||
<object
|
||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
||||
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
||||
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
@@ -675,7 +781,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{showTrash ? (
|
||||
/* Trash view */
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
{trashFiles.length > 0 && (
|
||||
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<button onClick={handleEmptyTrash} style={{
|
||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||
@@ -704,7 +810,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
) : (
|
||||
<>
|
||||
{/* Upload zone */}
|
||||
<div
|
||||
{can('file_upload', trip) && <div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
@@ -729,7 +835,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -118,6 +118,70 @@ const texts: Record<string, DemoTexts> = {
|
||||
selfHostLink: 'alójalo tú mismo',
|
||||
close: 'Entendido',
|
||||
},
|
||||
zh: {
|
||||
titleBefore: '欢迎来到 ',
|
||||
titleAfter: '',
|
||||
title: '欢迎来到 TREK 演示版',
|
||||
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
|
||||
resetIn: '下次重置将在',
|
||||
minutes: '分钟后',
|
||||
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
|
||||
fullVersionTitle: '完整版本还包括:',
|
||||
features: [
|
||||
'文件上传(照片、文档、封面)',
|
||||
'API 密钥管理(Google Maps、天气)',
|
||||
'用户和权限管理',
|
||||
'自动备份',
|
||||
'附加组件管理(启用/禁用)',
|
||||
'OIDC / SSO 单点登录',
|
||||
],
|
||||
addonsTitle: '模块化附加组件(完整版本可禁用)',
|
||||
addons: [
|
||||
['Vacay', '带日历、节假日和用户融合的假期规划器'],
|
||||
['Atlas', '带已访问国家和旅行统计的世界地图'],
|
||||
['Packing', '按旅行管理清单'],
|
||||
['Budget', '支持分摊的费用追踪'],
|
||||
['Documents', '将文件附加到旅行'],
|
||||
['Widgets', '货币换算和时区工具'],
|
||||
],
|
||||
whatIs: '什么是 TREK?',
|
||||
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
|
||||
selfHost: '开源项目 - ',
|
||||
selfHostLink: '自行部署',
|
||||
close: '知道了',
|
||||
},
|
||||
'zh-TW': {
|
||||
titleBefore: '歡迎來到 ',
|
||||
titleAfter: '',
|
||||
title: '歡迎來到 TREK 展示版',
|
||||
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
|
||||
resetIn: '下次重設將在',
|
||||
minutes: '分鐘後',
|
||||
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
|
||||
fullVersionTitle: '完整版本還包含:',
|
||||
features: [
|
||||
'檔案上傳(照片、文件、封面)',
|
||||
'API 金鑰管理(Google Maps、天氣)',
|
||||
'使用者與權限管理',
|
||||
'自動備份',
|
||||
'附加元件管理(啟用/停用)',
|
||||
'OIDC / SSO 單一登入',
|
||||
],
|
||||
addonsTitle: '模組化附加元件(完整版本可停用)',
|
||||
addons: [
|
||||
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
|
||||
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
|
||||
['Packing', '依行程管理的檢查清單'],
|
||||
['Budget', '支援分攤的費用追蹤'],
|
||||
['Documents', '將檔案附加到行程'],
|
||||
['Widgets', '貨幣換算與時區工具'],
|
||||
],
|
||||
whatIs: 'TREK 是什麼?',
|
||||
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
|
||||
selfHost: '開源專案 - ',
|
||||
selfHostLink: '自行架設',
|
||||
close: '知道了',
|
||||
},
|
||||
ar: {
|
||||
titleBefore: 'مرحبًا بك في ',
|
||||
titleAfter: '',
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||
|
||||
export default function InAppNotificationBell(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
||||
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchUnreadCount()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!open) {
|
||||
fetchNotifications(true)
|
||||
}
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
const handleShowAll = () => {
|
||||
setOpen(false)
|
||||
navigate('/notifications')
|
||||
}
|
||||
|
||||
const displayCount = unreadCount > 99 ? '99+' : unreadCount
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
title={t('notifications.title')}
|
||||
className="relative p-2 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
|
||||
style={{
|
||||
background: '#ef4444',
|
||||
fontSize: 9,
|
||||
minWidth: 14,
|
||||
height: 14,
|
||||
padding: '0 3px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
right: 8,
|
||||
width: 360,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
background: 'var(--bg-card)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--border-secondary)' }}
|
||||
>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={deleteAll}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} />
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
{t('notifications.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import ReactDOM from 'react-dom'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi } from '../../api/client'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||
|
||||
@@ -28,29 +29,21 @@ interface Addon {
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const loadAddons = () => {
|
||||
if (user) {
|
||||
addonsApi.enabled().then(data => {
|
||||
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
useEffect(loadAddons, [user, location.pathname])
|
||||
// Listen for addon changes from AddonManager
|
||||
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => loadAddons()
|
||||
window.addEventListener('addons-changed', handler)
|
||||
return () => window.removeEventListener('addons-changed', handler)
|
||||
}, [user])
|
||||
if (user) loadAddons()
|
||||
}, [user, location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
import('../../api/client').then(({ authApi }) => {
|
||||
@@ -140,7 +133,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{tripTitle && (
|
||||
<>
|
||||
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
||||
<span className="text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="hidden sm:inline text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
|
||||
{tripTitle}
|
||||
</span>
|
||||
</>
|
||||
@@ -162,15 +155,19 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) */}
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0"
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
||||
{user && tripId && <InAppNotificationBell />}
|
||||
{user && !tripId && <span className="hidden sm:block"><InAppNotificationBell /></span>}
|
||||
|
||||
{/* User menu */}
|
||||
{user && (
|
||||
<div className="relative">
|
||||
@@ -236,9 +233,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
{appVersion && (
|
||||
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||
} catch { return '' }
|
||||
}
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// Fix default marker icons for vite
|
||||
@@ -26,7 +34,12 @@ function escAttr(s) {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const iconCache = new Map<string, L.DivIcon>()
|
||||
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const size = isSelected ? 44 : 36
|
||||
const borderColor = isSelected ? '#111827' : 'white'
|
||||
const borderWidth = isSelected ? 3 : 2.5
|
||||
@@ -34,9 +47,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const bgColor = place.category_color || '#6b7280'
|
||||
const icon = place.category_icon || '📍'
|
||||
|
||||
// Number badges (bottom-right), supports multiple numbers for duplicate places
|
||||
// Number badges (bottom-right)
|
||||
let badgeHtml = ''
|
||||
if (orderNumbers && orderNumbers.length > 0) {
|
||||
const label = orderNumbers.join(' · ')
|
||||
@@ -54,18 +66,22 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
if (place.image_url) {
|
||||
return L.divIcon({
|
||||
// Base64 data URL thumbnails — no external image fetch during zoom
|
||||
// Only use base64 data URLs for markers — external URLs cause zoom lag
|
||||
if (place.image_url && place.image_url.startsWith('data:')) {
|
||||
const imgIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:visible;background:${bgColor};
|
||||
cursor:pointer;flex-shrink:0;position:relative;
|
||||
width:${size}px;height:${size}px;
|
||||
cursor:pointer;position:relative;
|
||||
">
|
||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
||||
<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:hidden;background:${bgColor};
|
||||
">
|
||||
<img src="${place.image_url}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
@@ -73,9 +89,11 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
iconCache.set(cacheKey, imgIcon)
|
||||
return imgIcon
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
const fallbackIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
@@ -84,14 +102,17 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
background:${bgColor};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;position:relative;
|
||||
will-change:transform;contain:layout style;
|
||||
">
|
||||
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
|
||||
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)}
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
iconCache.set(cacheKey, fallbackIcon)
|
||||
return fallbackIcon
|
||||
}
|
||||
|
||||
interface SelectionControllerProps {
|
||||
@@ -140,12 +161,13 @@ function MapController({ center, zoom }: MapControllerProps) {
|
||||
|
||||
// Fit bounds when places change (fitKey triggers re-fit)
|
||||
interface BoundsControllerProps {
|
||||
hasDayDetail?: boolean
|
||||
places: Place[]
|
||||
fitKey: number
|
||||
paddingOpts: Record<string, number>
|
||||
}
|
||||
|
||||
function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) {
|
||||
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
|
||||
@@ -155,9 +177,14 @@ function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps
|
||||
if (places.length === 0) return
|
||||
try {
|
||||
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||
if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||
if (hasDayDetail) {
|
||||
setTimeout(() => map.panBy([0, 150], { animate: true }), 300)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [fitKey, places, paddingOpts, map])
|
||||
}, [fitKey, places, paddingOpts, map, hasDayDetail])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -166,6 +193,16 @@ interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||
}
|
||||
|
||||
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
map.on('zoomstart', onZoomStart)
|
||||
map.on('zoomend', onZoomEnd)
|
||||
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
|
||||
}, [map, onZoomStart, onZoomEnd])
|
||||
return null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
@@ -237,8 +274,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
}
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
const mapPhotoCache = new Map()
|
||||
const mapPhotoInFlight = new Set()
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
@@ -330,7 +366,7 @@ function LocationTracker() {
|
||||
)
|
||||
}
|
||||
|
||||
export function MapView({
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
@@ -347,65 +383,122 @@ export function MapView({
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
}) {
|
||||
// Dynamic padding: account for sidebars + bottom inspector
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
const paddingOpts = useMemo(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (isMobile) return { padding: [40, 20] }
|
||||
const top = 60
|
||||
const bottom = hasInspector ? 320 : 60
|
||||
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||
const left = leftWidth + 40
|
||||
const right = rightWidth + 40
|
||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||
}, [leftWidth, rightWidth, hasInspector])
|
||||
const [photoUrls, setPhotoUrls] = useState({})
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Fetch photos for places with concurrency limit to avoid blocking map rendering
|
||||
// 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(() => {
|
||||
const queue = places.filter(place => {
|
||||
if (place.image_url) return false
|
||||
if (!places || places.length === 0) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
iconCache.clear()
|
||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
if (place.image_url && place.image_url.startsWith('data:')) continue
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) return false
|
||||
if (mapPhotoCache.has(cacheKey)) {
|
||||
const cached = mapPhotoCache.get(cacheKey)
|
||||
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
||||
return false
|
||||
if (!cacheKey) continue
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached?.thumbDataUrl) {
|
||||
setThumb(cacheKey, cached.thumbDataUrl)
|
||||
continue
|
||||
}
|
||||
if (mapPhotoInFlight.has(cacheKey)) return false
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
let active = 0
|
||||
const MAX_CONCURRENT = 3
|
||||
let idx = 0
|
||||
// Subscribe for when thumb becomes available
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
const fetchNext = () => {
|
||||
while (active < MAX_CONCURRENT && idx < queue.length) {
|
||||
const place = queue[idx++]
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
// Always fetch through API — returns fresh URL + converts to base64
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
mapPhotoInFlight.add(cacheKey)
|
||||
active++
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then(data => {
|
||||
if (data.photoUrl) {
|
||||
mapPhotoCache.set(cacheKey, data.photoUrl)
|
||||
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
||||
} else {
|
||||
mapPhotoCache.set(cacheKey, null)
|
||||
}
|
||||
})
|
||||
.catch(() => { mapPhotoCache.set(cacheKey, null) })
|
||||
.finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() })
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchNext()
|
||||
}, [places])
|
||||
|
||||
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),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
permanent={isTouchDevice && isSelected}
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
zoomControl={false}
|
||||
@@ -416,10 +509,14 @@ export function MapView({
|
||||
url={tileUrl}
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
maxZoom={19}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
@@ -427,71 +524,17 @@ export function MapView({
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
singleMarkerMode
|
||||
iconCreateFunction={(cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom"
|
||||
style="width:${size}px;height:${size}px;">
|
||||
<span>${count}</span>
|
||||
</div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}}
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
@@ -508,6 +551,24 @@ export function MapView({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{places.map((place) => {
|
||||
if (!place.route_geometry) return null
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return null
|
||||
return (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)
|
||||
} catch { return null }
|
||||
})}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function calculateRoute(
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
|
||||
import apiClient from '../../api/client'
|
||||
import apiClient, { addonsApi } from '../../api/client'
|
||||
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
interface PhotoProvider {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => {
|
||||
let revoke = ''
|
||||
fetchImageAsBlob('/api' + 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
|
||||
asset_id: string
|
||||
provider: string
|
||||
user_id: number
|
||||
username: string
|
||||
shared: number
|
||||
added_at: string
|
||||
city?: string | null
|
||||
}
|
||||
|
||||
interface ImmichAsset {
|
||||
interface Asset {
|
||||
id: string
|
||||
provider: string
|
||||
takenAt: string
|
||||
city: string | null
|
||||
country: string | null
|
||||
@@ -31,9 +57,13 @@ interface MemoriesPanelProps {
|
||||
|
||||
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 [enabledProviders, setEnabledProviders] = useState<PhotoProvider[]>([])
|
||||
const [availableProviders, setAvailableProviders] = useState<PhotoProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Trip photos (saved selections)
|
||||
@@ -41,7 +71,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// Photo picker
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
|
||||
const [pickerPhotos, setPickerPhotos] = useState<Asset[]>([])
|
||||
const [pickerLoading, setPickerLoading] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
@@ -52,11 +82,126 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
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; provider: string; 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)
|
||||
|
||||
|
||||
//helpers for building urls
|
||||
const ADDON_PREFIX = "/integrations/memories"
|
||||
|
||||
function buildUnifiedUrl(endpoint: string, lastParam?:string,): string {
|
||||
return `${ADDON_PREFIX}/unified/trips/${tripId}/${endpoint}${lastParam ? `/${lastParam}` : ''}`;
|
||||
}
|
||||
|
||||
function buildProviderUrl(provider: string, endpoint: string, item?: string): string {
|
||||
if (endpoint === 'album-link-sync') {
|
||||
endpoint = `trips/${tripId}/album-links/${item?.toString() || ''}/sync`
|
||||
}
|
||||
return `${ADDON_PREFIX}/${provider}/${endpoint}`;
|
||||
}
|
||||
|
||||
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
|
||||
return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
|
||||
}
|
||||
|
||||
function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
|
||||
const photo: TripPhoto = {
|
||||
asset_id: asset.id,
|
||||
provider: asset.provider,
|
||||
user_id: userId,
|
||||
username: '',
|
||||
shared: 0,
|
||||
added_at: null
|
||||
}
|
||||
return buildProviderAssetUrl(photo, what)
|
||||
}
|
||||
|
||||
|
||||
const loadAlbumLinks = async () => {
|
||||
try {
|
||||
const res = await apiClient.get(buildUnifiedUrl('album-links'))
|
||||
setAlbumLinks(res.data.links || [])
|
||||
} catch { setAlbumLinks([]) }
|
||||
}
|
||||
|
||||
const loadAlbums = async (provider: string = selectedProvider) => {
|
||||
if (!provider) return
|
||||
setAlbumsLoading(true)
|
||||
try {
|
||||
const res = await apiClient.get(buildProviderUrl(provider, 'albums'))
|
||||
setAlbums(res.data.albums || [])
|
||||
} catch {
|
||||
setAlbums([])
|
||||
toast.error(t('memories.error.loadAlbums'))
|
||||
} finally {
|
||||
setAlbumsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAlbumPicker = async () => {
|
||||
setShowAlbumPicker(true)
|
||||
await loadAlbums(selectedProvider)
|
||||
}
|
||||
|
||||
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||
if (!selectedProvider) {
|
||||
toast.error(t('memories.error.linkAlbum'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post(buildUnifiedUrl('album-links'), {
|
||||
album_id: albumId,
|
||||
album_name: albumName,
|
||||
provider: selectedProvider,
|
||||
})
|
||||
setShowAlbumPicker(false)
|
||||
await loadAlbumLinks()
|
||||
// Auto-sync after linking
|
||||
const linksRes = await apiClient.get(buildUnifiedUrl('album-links'))
|
||||
const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider)
|
||||
if (newLink) await syncAlbum(newLink.id)
|
||||
} catch { toast.error(t('memories.error.linkAlbum')) }
|
||||
}
|
||||
|
||||
const unlinkAlbum = async (linkId: number) => {
|
||||
try {
|
||||
await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString()))
|
||||
await loadAlbumLinks()
|
||||
await loadPhotos()
|
||||
} catch { toast.error(t('memories.error.unlinkAlbum')) }
|
||||
}
|
||||
|
||||
const syncAlbum = async (linkId: number, provider?: string) => {
|
||||
const targetProvider = provider || selectedProvider
|
||||
if (!targetProvider) return
|
||||
setSyncing(linkId)
|
||||
try {
|
||||
await apiClient.post(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString()))
|
||||
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('')
|
||||
const [showMobileInfo, setShowMobileInfo] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768)
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,7 +218,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
const loadPhotos = async () => {
|
||||
try {
|
||||
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
|
||||
const photosRes = await apiClient.get(buildUnifiedUrl('photos'))
|
||||
setTripPhotos(photosRes.data.photos || [])
|
||||
} catch {
|
||||
setTripPhotos([])
|
||||
@@ -83,12 +228,41 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
const loadInitial = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const statusRes = await apiClient.get('/integrations/immich/status')
|
||||
setConnected(statusRes.data.connected)
|
||||
const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] }))
|
||||
const enabledAddons = addonsRes?.addons || []
|
||||
const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled)
|
||||
|
||||
setEnabledProviders(photoProviders.map((a: any) => ({ id: a.id, name: a.name, icon: a.icon, config: a.config })))
|
||||
|
||||
// Test connection status for each enabled provider
|
||||
const statusResults = await Promise.all(
|
||||
photoProviders.map(async (provider: any) => {
|
||||
const statusUrl = (provider.config as Record<string, unknown>)?.status_get as string
|
||||
if (!statusUrl) return { provider, connected: false }
|
||||
try {
|
||||
const res = await apiClient.get(statusUrl)
|
||||
return { provider, connected: !!res.data?.connected }
|
||||
} catch {
|
||||
return { provider, connected: false }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const connectedProviders = statusResults
|
||||
.filter(r => r.connected)
|
||||
.map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config }))
|
||||
|
||||
setAvailableProviders(connectedProviders)
|
||||
setConnected(connectedProviders.length > 0)
|
||||
if (connectedProviders.length > 0 && !selectedProvider) {
|
||||
setSelectedProvider(connectedProviders[0].id)
|
||||
}
|
||||
} catch {
|
||||
setAvailableProviders([])
|
||||
setConnected(false)
|
||||
}
|
||||
await loadPhotos()
|
||||
await loadAlbumLinks()
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -104,16 +278,38 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
await loadPickerPhotos(!!(startDate && endDate))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showPicker) {
|
||||
loadPickerPhotos(pickerDateFilter)
|
||||
}
|
||||
}, [selectedProvider])
|
||||
|
||||
useEffect(() => {
|
||||
loadAlbumLinks()
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (showAlbumPicker) {
|
||||
loadAlbums(selectedProvider)
|
||||
}
|
||||
}, [showAlbumPicker, selectedProvider, tripId])
|
||||
|
||||
const loadPickerPhotos = async (useDate: boolean) => {
|
||||
setPickerLoading(true)
|
||||
try {
|
||||
const res = await apiClient.post('/integrations/immich/search', {
|
||||
const provider = availableProviders.find(p => p.id === selectedProvider)
|
||||
if (!provider) {
|
||||
setPickerPhotos([])
|
||||
return
|
||||
}
|
||||
const res = await apiClient.post(buildProviderUrl(provider.id, 'search'), {
|
||||
from: useDate && startDate ? startDate : undefined,
|
||||
to: useDate && endDate ? endDate : undefined,
|
||||
})
|
||||
setPickerPhotos(res.data.assets || [])
|
||||
setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id })))
|
||||
} catch {
|
||||
setPickerPhotos([])
|
||||
toast.error(t('memories.error.loadPhotos'))
|
||||
} finally {
|
||||
setPickerLoading(false)
|
||||
}
|
||||
@@ -136,44 +332,59 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
const executeAddPhotos = async () => {
|
||||
setShowConfirmShare(false)
|
||||
try {
|
||||
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
|
||||
asset_ids: [...selectedIds],
|
||||
const groupedByProvider = new Map<string, string[]>()
|
||||
for (const key of selectedIds) {
|
||||
const [provider, assetId] = key.split('::')
|
||||
if (!provider || !assetId) continue
|
||||
const list = groupedByProvider.get(provider) || []
|
||||
list.push(assetId)
|
||||
groupedByProvider.set(provider, list)
|
||||
}
|
||||
|
||||
await apiClient.post(buildUnifiedUrl('photos'), {
|
||||
selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })),
|
||||
shared: true,
|
||||
})
|
||||
setShowPicker(false)
|
||||
clearImageQueue()
|
||||
loadInitial()
|
||||
} catch {}
|
||||
} catch { toast.error(t('memories.error.addPhotos')) }
|
||||
}
|
||||
|
||||
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||
|
||||
const removePhoto = async (assetId: string) => {
|
||||
const removePhoto = async (photo: TripPhoto) => {
|
||||
try {
|
||||
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||
} catch {}
|
||||
await apiClient.delete(buildUnifiedUrl('photos'), {
|
||||
data: {
|
||||
asset_id: photo.asset_id,
|
||||
provider: photo.provider,
|
||||
},
|
||||
})
|
||||
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
|
||||
} catch { toast.error(t('memories.error.removePhoto')) }
|
||||
}
|
||||
|
||||
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||
|
||||
const toggleSharing = async (assetId: string, shared: boolean) => {
|
||||
const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
|
||||
try {
|
||||
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
|
||||
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
|
||||
shared,
|
||||
asset_id: photo.asset_id,
|
||||
provider: photo.provider,
|
||||
})
|
||||
setTripPhotos(prev => prev.map(p =>
|
||||
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
))
|
||||
} catch {}
|
||||
} catch { toast.error(t('memories.error.toggleSharing')) }
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const token = useAuthStore(s => s.token)
|
||||
|
||||
|
||||
const thumbnailUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
|
||||
|
||||
const originalUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
|
||||
const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
|
||||
|
||||
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||
@@ -213,10 +424,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
<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')}
|
||||
{t('memories.notConnected', { provider_name: enabledProviders.length === 1 ? enabledProviders[0]?.name : 'Photo provider' })}
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
|
||||
{t('memories.notConnectedHint')}
|
||||
{enabledProviders.length === 1 ? t('memories.notConnectedHint', { provider_name: enabledProviders[0]?.name }) : t('memories.notConnectedMultipleHint', { provider_names: enabledProviders.map(p => p.name).join(', ') })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -224,8 +435,109 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||
|
||||
const ProviderTabs = () => {
|
||||
if (availableProviders.length < 2) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
{availableProviders.map(provider => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => setSelectedProvider(provider.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 99,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
border: '1px solid',
|
||||
transition: 'all 0.15s',
|
||||
background: selectedProvider === provider.id ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: selectedProvider === provider.id ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: selectedProvider === provider.id ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Album Picker Modal ──────────────────────────────────────────────────
|
||||
|
||||
if (showAlbumPicker) {
|
||||
const linkedIds = new Set(albumLinks.map(l => l.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)' }}>
|
||||
{availableProviders.length > 1 ? t('memories.selectAlbumMultiple') : t('memories.selectAlbum', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
|
||||
</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>
|
||||
<ProviderTabs />
|
||||
</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))
|
||||
const alreadyAdded = new Set(
|
||||
tripPhotos
|
||||
.filter(p => p.user_id === currentUser?.id)
|
||||
.map(p => makePickerKey(p.provider, p.asset_id))
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -234,10 +546,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
<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')}
|
||||
{availableProviders.length > 1 ? t('memories.selectPhotosMultiple') : t('memories.selectPhotos', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setShowPicker(false)}
|
||||
<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>
|
||||
@@ -252,6 +564,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<ProviderTabs />
|
||||
</div>
|
||||
{/* Filter tabs */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{startDate && endDate && (
|
||||
@@ -295,10 +610,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
<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>
|
||||
{
|
||||
pickerDateFilter && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
|
||||
{t('memories.noPhotosHint', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
) : (() => {
|
||||
// Group photos by month
|
||||
const byMonth: Record<string, ImmichAsset[]> = {}
|
||||
const byMonth: Record<string, Asset[]> = {}
|
||||
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'
|
||||
@@ -316,11 +638,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</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)
|
||||
const pickerKey = makePickerKey(asset.provider, asset.id)
|
||||
const isSelected = selectedIds.has(pickerKey)
|
||||
const isAlready = alreadyAdded.has(pickerKey)
|
||||
return (
|
||||
<div key={asset.id}
|
||||
onClick={() => !isAlready && togglePickerSelect(asset.id)}
|
||||
<div key={pickerKey}
|
||||
onClick={() => !isAlready && togglePickerSelect(pickerKey)}
|
||||
style={{
|
||||
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
||||
cursor: isAlready ? 'default' : 'pointer',
|
||||
@@ -328,7 +651,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||
outlineOffset: -3,
|
||||
}}>
|
||||
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
||||
<ProviderImg baseUrl={buildProviderAssetUrlFromAsset(asset, 'thumbnail', currentUser!.id)} provider={asset.provider} loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{isSelected && (
|
||||
<div style={{
|
||||
@@ -404,16 +727,52 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</p>
|
||||
</div>
|
||||
{connected && (
|
||||
<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 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, link.provider)} 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 */}
|
||||
@@ -446,12 +805,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{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' }}>
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 12px' }}>
|
||||
{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,
|
||||
@@ -466,16 +822,19 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{allVisible.map(photo => {
|
||||
const isOwn = photo.user_id === currentUser?.id
|
||||
return (
|
||||
<div key={photo.immich_asset_id} className="group"
|
||||
<div key={`${photo.provider}:${photo.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)
|
||||
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||
setLightboxOriginalSrc('')
|
||||
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
|
||||
setLightboxInfoLoading(true)
|
||||
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||
apiClient.get(buildProviderAssetUrl(photo, 'info'))
|
||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||
}}>
|
||||
|
||||
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
||||
<ProviderImg baseUrl={buildProviderAssetUrl(photo, 'thumbnail')} provider={photo.provider} loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||
|
||||
{/* Other user's avatar */}
|
||||
@@ -506,7 +865,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{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) }}
|
||||
<button onClick={e => { e.stopPropagation(); toggleSharing(photo, !photo.shared) }}
|
||||
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
@@ -515,7 +874,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
}}>
|
||||
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
|
||||
<button onClick={e => { e.stopPropagation(); removePhoto(photo) }}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
@@ -576,117 +935,193 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxId && lightboxUserId && (
|
||||
<div onClick={() => { 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={() => { 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={originalUrl(lightboxId, lightboxUserId)}
|
||||
alt=""
|
||||
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||
/>
|
||||
{lightboxId && lightboxUserId && (() => {
|
||||
const closeLightbox = () => {
|
||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||
setLightboxOriginalSrc('')
|
||||
setLightboxId(null)
|
||||
setLightboxUserId(null)
|
||||
setShowMobileInfo(false)
|
||||
}
|
||||
|
||||
{/* 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 && (
|
||||
const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
|
||||
const hasPrev = currentIdx > 0
|
||||
const hasNext = currentIdx < allVisible.length - 1
|
||||
const navigateTo = (idx: number) => {
|
||||
const photo = allVisible[idx]
|
||||
if (!photo) return
|
||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||
setLightboxOriginalSrc('')
|
||||
setLightboxId(photo.asset_id)
|
||||
setLightboxUserId(photo.user_id)
|
||||
setLightboxInfo(null)
|
||||
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
|
||||
}
|
||||
|
||||
const exifContent = lightboxInfo ? (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
{(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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{lightboxInfo.focalLength && (
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{(lightboxInfo.city || lightboxInfo.country) && (
|
||||
{lightboxInfo.aperture && (
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{/* Camera */}
|
||||
{lightboxInfo.camera && (
|
||||
{lightboxInfo.shutter && (
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{(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>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
|
||||
{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' }} />
|
||||
return (
|
||||
<div onClick={closeLightbox}
|
||||
onKeyDown={e => { if (e.key === 'ArrowLeft' && hasPrev) navigateTo(currentIdx - 1); if (e.key === 'ArrowRight' && hasNext) navigateTo(currentIdx + 1); if (e.key === 'Escape') closeLightbox() }}
|
||||
tabIndex={0} ref={el => el?.focus()}
|
||||
onTouchStart={e => (e.currentTarget as any)._touchX = e.touches[0].clientX}
|
||||
onTouchEnd={e => { const start = (e.currentTarget as any)._touchX; if (start == null) return; const diff = e.changedTouches[0].clientX - start; if (diff > 60 && hasPrev) navigateTo(currentIdx - 1); else if (diff < -60 && hasNext) navigateTo(currentIdx + 1) }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100, outline: 'none',
|
||||
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{/* Close button */}
|
||||
<button onClick={closeLightbox}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16, zIndex: 10, 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>
|
||||
|
||||
{/* Counter */}
|
||||
{allVisible.length > 1 && (
|
||||
<div style={{ position: 'absolute', top: 20, left: 20, zIndex: 10, fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>
|
||||
{currentIdx + 1} / {allVisible.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prev/Next buttons */}
|
||||
{hasPrev && (
|
||||
<button onClick={e => { e.stopPropagation(); navigateTo(currentIdx - 1) }}
|
||||
style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', zIndex: 10, background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'rgba(255,255,255,0.8)' }}>
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
)}
|
||||
{hasNext && (
|
||||
<button onClick={e => { e.stopPropagation(); navigateTo(currentIdx + 1) }}
|
||||
style={{ position: 'absolute', right: isMobile ? 12 : 280, top: '50%', transform: 'translateY(-50%)', zIndex: 10, background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'rgba(255,255,255,0.8)' }}>
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile info toggle button */}
|
||||
{isMobile && (lightboxInfo || lightboxInfoLoading) && (
|
||||
<button onClick={e => { e.stopPropagation(); setShowMobileInfo(prev => !prev) }}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 68, zIndex: 10, width: 40, height: 40, borderRadius: '50%',
|
||||
background: showMobileInfo ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.1)',
|
||||
border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Info size={20} color="white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div onClick={e => { if (e.target === e.currentTarget) closeLightbox() }} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||
<img
|
||||
src={lightboxOriginalSrc}
|
||||
alt=""
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ maxWidth: (!isMobile && lightboxInfo) ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||
/>
|
||||
|
||||
{/* Desktop info panel — liquid glass */}
|
||||
{!isMobile && 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',
|
||||
}}>
|
||||
{exifContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMobile && 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>
|
||||
|
||||
{/* Mobile bottom sheet */}
|
||||
{isMobile && showMobileInfo && lightboxInfo && (
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 5,
|
||||
maxHeight: '60vh', overflowY: 'auto',
|
||||
borderRadius: '16px 16px 0 0', padding: 18,
|
||||
background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)', borderBottom: 'none',
|
||||
color: 'white', display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
{exifContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d`
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const [responding, setResponding] = useState(false)
|
||||
|
||||
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore()
|
||||
|
||||
const handleNavigate = async () => {
|
||||
if (!notification.is_read) await markRead(notification.id)
|
||||
if (notification.navigate_target) {
|
||||
navigate(notification.navigate_target)
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRespond = async (response: 'positive' | 'negative') => {
|
||||
if (responding || notification.response !== null) return
|
||||
setResponding(true)
|
||||
await respondToBoolean(notification.id, response)
|
||||
setResponding(false)
|
||||
}
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative px-4 py-3 transition-colors"
|
||||
style={{
|
||||
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
|
||||
borderBottom: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{!notification.is_read && (
|
||||
<button
|
||||
onClick={() => markRead(notification.id)}
|
||||
title={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'positive'
|
||||
? 'var(--text-primary)'
|
||||
: notification.response === 'negative'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'positive'
|
||||
? '#fff'
|
||||
: notification.response === 'negative'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
opacity: notification.response === 'negative' ? 0.5 : 1,
|
||||
cursor: notification.response !== null || responding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'negative'
|
||||
? '#ef4444'
|
||||
: notification.response === 'positive'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'negative'
|
||||
? '#fff'
|
||||
: notification.response === 'positive'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
opacity: notification.response === 'positive' ? 0.5 : 1,
|
||||
cursor: notification.response !== null || responding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<button
|
||||
onClick={handleNavigate}
|
||||
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9'}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
// Trip PDF via browser print window
|
||||
import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react'
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
return _renderToStaticMarkup(
|
||||
createElement(icon, props)
|
||||
);
|
||||
}
|
||||
|
||||
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
|
||||
function noteIconSvg(iconId) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
const Icon = NOTE_ICON_MAP[iconId] || FileText
|
||||
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
|
||||
}
|
||||
|
||||
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
function transportIconSvg(type) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
|
||||
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }))
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })
|
||||
}
|
||||
|
||||
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
|
||||
function accommodationIconSvg(type) {
|
||||
const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' })
|
||||
}
|
||||
|
||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||
@@ -61,15 +72,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
|
||||
|
||||
function shortDate(d, locale) {
|
||||
if (!d) return ''
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function longDateRange(days, locale) {
|
||||
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
|
||||
if (!dd.length) return null
|
||||
const f = new Date(dd[0].date + 'T00:00:00')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
|
||||
const f = new Date(dd[0].date + 'T00:00:00Z')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
|
||||
}
|
||||
|
||||
function dayCost(assignments, dayId, locale) {
|
||||
@@ -115,6 +126,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||
const range = longDateRange(sorted, loc)
|
||||
const coverImg = safeImg(trip?.cover_image)
|
||||
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
||||
const accommodations = await accommodationsApi.list(trip.id);
|
||||
|
||||
// Pre-fetch place photos from Google
|
||||
const photoMap = await fetchPlacePhotos(assignments)
|
||||
@@ -223,7 +236,41 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
}).join('')
|
||||
|
||||
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
).sort((a, b) => a.start_day_id - b.start_day_id)
|
||||
|
||||
const accommodationDetails = accommodationsForDay.map(item => {
|
||||
const isCheckIn = day.id === item.start_day_id
|
||||
const isCheckOut = day.id === item.end_day_id
|
||||
const actionLabel = isCheckIn ? tr('reservations.meta.checkIn')
|
||||
: isCheckOut ? tr('reservations.meta.checkOut')
|
||||
: tr('reservations.meta.linkAccommodation')
|
||||
const actionIcon = isCheckIn ? accommodationIconSvg('checkin')
|
||||
: isCheckOut ? accommodationIconSvg('checkout')
|
||||
: accommodationIconSvg('accommodation')
|
||||
const timeStr = isCheckIn ? (item.check_in || '')
|
||||
: isCheckOut ? (item.check_out || '')
|
||||
: ''
|
||||
|
||||
return `
|
||||
<div class="day-accommodation">
|
||||
<div class="day-accommodation-title accommodation-center-icon">${actionIcon} ${escHtml(actionLabel)}</div>
|
||||
${timeStr ? `<div class="accommodation-center-icon">${accommodationIconSvg('checkin')} <b>${escHtml(timeStr)}</b></div>` : ''}
|
||||
<div class="accommodation-center-icon">${accommodationIconSvg('accommodation')} ${escHtml(item.place_name)}</div>
|
||||
${item.place_address ? `<div class="accommodation-center-icon">${accommodationIconSvg('location')} ${escHtml(item.place_address)}</div>` : ''}
|
||||
${item.notes ? `<div class="accommodation-center-icon">${accommodationIconSvg('note')} ${escHtml(item.notes)}</div>` : ''}
|
||||
${isCheckIn && item.confirmation ? `<div class="accommodation-center-icon">${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}</div>` : ''}
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
const accommodationsHtml = accommodationsForDay.length > 0
|
||||
? `<div class="day-accommodations-overview">
|
||||
<div class="day-accommodations ${accommodationsForDay.length === 1 ? 'single' : ''}">${accommodationDetails}</div>
|
||||
</div>`
|
||||
: ''
|
||||
|
||||
return `
|
||||
<div class="day-section${di > 0 ? ' page-break' : ''}">
|
||||
@@ -233,8 +280,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
|
||||
${cost ? `<span class="day-cost">${cost}</span>` : ''}
|
||||
</div>
|
||||
<div class="day-body">${itemsHtml}</div>
|
||||
</div>`
|
||||
<div class="day-body">${accommodationsHtml}${itemsHtml}</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
@@ -317,6 +364,22 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
|
||||
.day-body { padding: 12px 28px 6px; }
|
||||
|
||||
/* accommodation info */
|
||||
.day-accommodations-overview { font-size: 12px; }
|
||||
.day-accommodations { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; }
|
||||
.day-accommodations.single { justify-content: center; }
|
||||
.day-accommodation {
|
||||
flex: 1 1 45%; min-width: 200px; margin: 4px 0; padding: 10px;
|
||||
border: 2px solid #e2e8f0; border-radius: 12px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.day-accommodation-title {
|
||||
font-size: 16px; font-weight: 600; text-align: center;
|
||||
margin-bottom: 4px; align-self: center;
|
||||
}
|
||||
.accommodation-center-icon { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
|
||||
/* ── Place card ────────────────────────────────── */
|
||||
.place-card {
|
||||
display: flex; align-items: stretch;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||
@@ -66,7 +67,134 @@ function katColor(kat, allCategories) {
|
||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
||||
}
|
||||
|
||||
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null }
|
||||
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
||||
|
||||
// ── Bag Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface BagCardProps {
|
||||
bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number
|
||||
tripMembers: TripMember[]; canEdit: boolean; onDelete: () => void
|
||||
onUpdate: (bagId: number, data: Record<string, any>) => void
|
||||
onSetMembers: (bagId: number, userIds: number[]) => void; t: any; compact?: boolean
|
||||
}
|
||||
|
||||
function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, canEdit, onDelete, onUpdate, onSetMembers, t, compact }: BagCardProps) {
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameVal, setNameVal] = useState(bag.name)
|
||||
const [showUserPicker, setShowUserPicker] = useState(false)
|
||||
useEffect(() => setNameVal(bag.name), [bag.name])
|
||||
|
||||
const saveName = () => {
|
||||
if (nameVal.trim() && nameVal.trim() !== bag.name) onUpdate(bag.id, { name: nameVal.trim() })
|
||||
setEditingName(false)
|
||||
}
|
||||
|
||||
const memberIds = (bag.members || []).map(m => m.user_id)
|
||||
const toggleMember = (userId: number) => {
|
||||
const next = memberIds.includes(userId) ? memberIds.filter(id => id !== userId) : [...memberIds, userId]
|
||||
onSetMembers(bag.id, next)
|
||||
}
|
||||
|
||||
const sz = compact ? { dot: 10, name: 12, weight: 11, bar: 6, count: 10, gap: 6, mb: 14, icon: 11, avatar: 18 } : { dot: 12, name: 14, weight: 13, bar: 8, count: 11, gap: 8, mb: 16, icon: 13, avatar: 22 }
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: sz.mb }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: sz.gap, marginBottom: 4 }}>
|
||||
<span style={{ width: sz.dot, height: sz.dot, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
|
||||
{editingName && canEdit ? (
|
||||
<input autoFocus value={nameVal} onChange={e => setNameVal(e.target.value)}
|
||||
onBlur={saveName} onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') { setEditingName(false); setNameVal(bag.name) } }}
|
||||
style={{ flex: 1, fontSize: sz.name, fontWeight: 600, padding: '1px 4px', borderRadius: 4, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', color: 'var(--text-primary)', background: 'transparent' }} />
|
||||
) : (
|
||||
<span onClick={() => canEdit && setEditingName(true)} style={{ flex: 1, fontSize: sz.name, fontWeight: 600, color: compact ? 'var(--text-secondary)' : 'var(--text-primary)', cursor: canEdit ? 'text' : 'default' }}>{bag.name}</span>
|
||||
)}
|
||||
<span style={{ fontSize: sz.weight, color: 'var(--text-faint)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
{canEdit && <button onClick={onDelete} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}><X size={sz.icon} /></button>}
|
||||
</div>
|
||||
{/* Members */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, flexWrap: 'wrap', position: 'relative' }}>
|
||||
{(bag.members || []).map(m => (
|
||||
<span key={m.user_id} title={m.username} onClick={() => canEdit && toggleMember(m.user_id)} style={{ cursor: canEdit ? 'pointer' : 'default', display: 'inline-flex' }}>
|
||||
{m.avatar ? (
|
||||
<img src={m.avatar} alt={m.username} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', objectFit: 'cover', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }} />
|
||||
) : (
|
||||
<span style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', background: bag.color + '25', color: bag.color, fontSize: sz.avatar * 0.45, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }}>
|
||||
{m.username[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowUserPicker(v => !v)} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', border: '1.5px dashed var(--border-primary)', background: 'none', color: 'var(--text-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, boxSizing: 'border-box' }}>
|
||||
<Plus size={sz.avatar * 0.5} />
|
||||
</button>
|
||||
)}
|
||||
{showUserPicker && (
|
||||
<div style={{ position: 'absolute', left: 0, top: '100%', marginTop: 4, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', padding: 4, minWidth: 160 }}>
|
||||
{tripMembers.map(m => {
|
||||
const isSelected = memberIds.includes(m.id)
|
||||
return (
|
||||
<button key={m.id} onClick={() => { toggleMember(m.id); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: isSelected ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-primary)', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-secondary)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
|
||||
{m.avatar ? (
|
||||
<img src={m.avatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span style={{ width: 20, height: 20, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 10, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
|
||||
{m.username[0].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1, fontWeight: isSelected ? 600 : 400 }}>{m.username}</span>
|
||||
{isSelected && <Check size={12} style={{ color: '#10b981' }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}>
|
||||
<button onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'center' }}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: sz.bar, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: sz.count, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Quantity Input ─────────────────────────────────────────────────────────
|
||||
|
||||
function QuantityInput({ value, onSave }: { value: number; onSave: (qty: number) => void }) {
|
||||
const [local, setLocal] = useState(String(value))
|
||||
useEffect(() => setLocal(String(value)), [value])
|
||||
|
||||
const commit = () => {
|
||||
const qty = Math.max(1, Math.min(999, Number(local) || 1))
|
||||
setLocal(String(qty))
|
||||
if (qty !== value) onSave(qty)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', flexShrink: 0 }}>
|
||||
<input
|
||||
type="text" inputMode="numeric"
|
||||
value={local}
|
||||
onChange={e => setLocal(e.target.value.replace(/\D/g, ''))}
|
||||
onBlur={commit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
style={{ width: 24, border: 'none', outline: 'none', background: 'transparent', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', color: 'var(--text-secondary)', padding: 0 }}
|
||||
/>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>x</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
||||
interface ArtikelZeileProps {
|
||||
@@ -77,9 +205,10 @@ interface ArtikelZeileProps {
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) {
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(item.name)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
@@ -130,7 +259,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
</button>
|
||||
|
||||
{editing ? (
|
||||
{editing && canEdit ? (
|
||||
<input
|
||||
type="text" value={editName} autoFocus
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
@@ -140,10 +269,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => !item.checked && setEditing(true)}
|
||||
onClick={() => canEdit && !item.checked && setEditing(true)}
|
||||
style={{
|
||||
flex: 1, fontSize: 13.5,
|
||||
cursor: item.checked ? 'default' : 'text',
|
||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
}}
|
||||
@@ -152,6 +281,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Quantity */}
|
||||
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
|
||||
|
||||
{/* Weight + Bag (when enabled) */}
|
||||
{bagTrackingEnabled && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
@@ -159,7 +291,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
<input
|
||||
type="text" inputMode="numeric"
|
||||
value={item.weight_grams ?? ''}
|
||||
readOnly={!canEdit}
|
||||
onChange={async e => {
|
||||
if (!canEdit) return
|
||||
const raw = e.target.value.replace(/[^0-9]/g, '')
|
||||
const v = raw === '' ? null : parseInt(raw)
|
||||
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
|
||||
@@ -171,9 +305,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowBagPicker(p => !p)}
|
||||
onClick={() => canEdit && setShowBagPicker(p => !p)}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
|
||||
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
|
||||
}}
|
||||
@@ -247,6 +381,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
@@ -287,6 +422,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -319,9 +455,10 @@ interface KategorieGruppeProps {
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) {
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editKatName, setEditKatName] = useState(kategorie)
|
||||
@@ -380,7 +517,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName ? (
|
||||
{editingName && canEdit ? (
|
||||
<input
|
||||
autoFocus value={editKatName}
|
||||
onChange={e => setEditKatName(e.target.value)}
|
||||
@@ -398,11 +535,11 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
|
||||
{assignees.map(a => (
|
||||
<div key={a.user_id} style={{ position: 'relative' }}
|
||||
onClick={e => { e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
||||
onClick={e => { e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
|
||||
>
|
||||
<div className="assignee-chip"
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: 'pointer',
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
|
||||
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||
@@ -422,6 +559,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{canEdit && (
|
||||
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
|
||||
style={{
|
||||
@@ -479,6 +617,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
@@ -497,11 +636,13 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
{showMenu && (
|
||||
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
|
||||
onMouseLeave={() => setShowMenu(false)}>
|
||||
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
|
||||
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
{canEdit && <>
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -510,10 +651,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
{offen && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => (
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||
))}
|
||||
{/* Inline add item */}
|
||||
{showAddItem ? (
|
||||
{canEdit && (showAddItem ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
|
||||
<input
|
||||
ref={addItemRef}
|
||||
@@ -548,7 +689,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Plus size={12} /> {t('packing.addItem')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -589,6 +730,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('packing_edit', trip)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -724,10 +868,26 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
} catch { toast.error(t('packing.toast.deleteError')) }
|
||||
}
|
||||
|
||||
const handleUpdateBag = async (bagId: number, data: Record<string, any>) => {
|
||||
try {
|
||||
const result = await packingApi.updateBag(tripId, bagId, data)
|
||||
setBags(prev => prev.map(b => b.id === bagId ? { ...b, ...result.bag } : b))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
const handleSetBagMembers = async (bagId: number, userIds: number[]) => {
|
||||
try {
|
||||
const result = await packingApi.setBagMembers(tripId, bagId, userIds)
|
||||
setBags(prev => prev.map(b => b.id === bagId ? { ...b, members: result.members } : b))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
// Templates
|
||||
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
|
||||
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
|
||||
const [applyingTemplate, setApplyingTemplate] = useState(false)
|
||||
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
|
||||
const [saveTemplateName, setSaveTemplateName] = useState('')
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importText, setImportText] = useState('')
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -761,10 +921,38 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAsTemplate = async () => {
|
||||
if (!saveTemplateName.trim()) return
|
||||
try {
|
||||
await packingApi.saveAsTemplate(tripId, saveTemplateName.trim())
|
||||
toast.success(t('packing.templateSaved'))
|
||||
setShowSaveTemplate(false)
|
||||
setSaveTemplateName('')
|
||||
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CSV line respecting quoted values (e.g. "Shirt, blue" stays as one field)
|
||||
const parseCsvLine = (line: string): string[] => {
|
||||
const parts: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]
|
||||
if (ch === '"') { inQuotes = !inQuotes; continue }
|
||||
if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) { parts.push(current.trim()); current = ''; continue }
|
||||
current += ch
|
||||
}
|
||||
parts.push(current.trim())
|
||||
return parts
|
||||
}
|
||||
|
||||
const parseImportLines = (text: string) => {
|
||||
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
|
||||
// Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional)
|
||||
const parts = line.split(/[,;\t]/).map(s => s.trim())
|
||||
const parts = parseCsvLine(line)
|
||||
if (parts.length >= 2) {
|
||||
const category = parts[0]
|
||||
const name = parts[1]
|
||||
@@ -814,7 +1002,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{abgehakt > 0 && (
|
||||
{canEdit && abgehakt > 0 && (
|
||||
<button onClick={handleClearChecked} style={{
|
||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -823,6 +1011,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowImportModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
@@ -830,7 +1019,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
}}>
|
||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
{availableTemplates.length > 0 && (
|
||||
)}
|
||||
{canEdit && availableTemplates.length > 0 && (
|
||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
@@ -869,6 +1059,32 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && items.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{showSaveTemplate ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input
|
||||
type="text" autoFocus
|
||||
value={saveTemplateName}
|
||||
onChange={e => setSaveTemplateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
|
||||
placeholder={t('packing.templateName')}
|
||||
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
|
||||
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{bagTrackingEnabled && (
|
||||
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
|
||||
style={{
|
||||
@@ -899,7 +1115,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addingCategory ? (
|
||||
{canEdit && (addingCategory ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
autoFocus
|
||||
@@ -924,7 +1140,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<FolderPlus size={14} /> {t('packing.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Filter-Tabs ── */}
|
||||
@@ -972,6 +1188,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
bagTrackingEnabled={bagTrackingEnabled}
|
||||
bags={bags}
|
||||
onCreateBag={handleCreateBagByName}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -991,23 +1208,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||
return (
|
||||
<div key={bag.id} style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{bag.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
<button onClick={() => handleDeleteBag(bag.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
|
||||
</div>
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1039,7 +1240,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
|
||||
{/* Add bag */}
|
||||
{showAddBag ? (
|
||||
{canEdit && (showAddBag ? (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
|
||||
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||
@@ -1054,16 +1255,16 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
|
||||
<Plus size={11} /> {t('packing.addBag')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Bag Modal (mobile + click) ── */}
|
||||
{showBagModal && bagTrackingEnabled && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
|
||||
onClick={() => setShowBagModal(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: '80vh', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)' }}
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
|
||||
@@ -1076,23 +1277,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||
return (
|
||||
<div key={bag.id} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{bag.name}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
<button onClick={() => handleDeleteBag(bag.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
|
||||
</div>
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1124,7 +1309,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
|
||||
{/* Add bag */}
|
||||
{showAddBag ? (
|
||||
{canEdit && (showAddBag ? (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
|
||||
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||
@@ -1142,7 +1327,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Plus size={14} /> {t('packing.addBag')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1166,18 +1351,29 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder={t('packing.importPlaceholder')}
|
||||
style={{
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
|
||||
background: 'var(--bg-input)', resize: 'vertical', lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--border-primary)', borderRadius: 10, overflow: 'hidden', background: 'var(--bg-input)' }}>
|
||||
<div style={{
|
||||
padding: '10px 0', fontSize: 13, fontFamily: 'monospace', lineHeight: 1.5,
|
||||
color: 'var(--text-faint)', textAlign: 'right', userSelect: 'none',
|
||||
background: 'var(--bg-hover)', borderRight: '1px solid var(--border-faint)',
|
||||
minWidth: 32, flexShrink: 0,
|
||||
}}>
|
||||
{(importText || ' ').split('\n').map((_, i) => (
|
||||
<div key={i} style={{ padding: '0 6px' }}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder={t('packing.importPlaceholder')}
|
||||
style={{
|
||||
flex: 1, border: 'none', padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
|
||||
background: 'transparent', resize: 'vertical', lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
|
||||
|
||||
@@ -213,5 +213,5 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
|
||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
import { weatherApi, accommodationsApi } from '../../api/client'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -56,6 +58,9 @@ interface DayDetailPanelProps {
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canEditDays = can('day_edit', tripObj)
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
@@ -111,8 +116,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
setAccommodation(data.accommodation)
|
||||
setAccommodations(prev => [...prev, data.accommodation])
|
||||
const newAcc = data.accommodation
|
||||
const updated = [...accommodations, newAcc]
|
||||
setAccommodations(updated)
|
||||
setAccommodation(newAcc)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
))
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
onAccommodationChange?.()
|
||||
@@ -132,7 +142,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
if (!accommodation) return
|
||||
try {
|
||||
await accommodationsApi.delete(tripId, accommodation.id)
|
||||
setAccommodations(prev => prev.filter(a => a.id !== accommodation.id))
|
||||
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||
setAccommodations(updated)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
))
|
||||
setAccommodation(null)
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
@@ -140,9 +154,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
|
||||
if (!day) return null
|
||||
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(
|
||||
getLocaleForLanguage(language),
|
||||
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
|
||||
) : null
|
||||
|
||||
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||
@@ -328,13 +342,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
|
||||
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
||||
</div>
|
||||
<button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
</button>}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
{/* Details grid */}
|
||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||
@@ -385,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
)
|
||||
})}
|
||||
{/* Add another hotel */}
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
{canEditDays && <button onClick={() => setShowHotelPicker(true)} style={{
|
||||
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Hotel size={10} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
canEditDays ? <button onClick={() => setShowHotelPicker(true)} style={{
|
||||
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Hotel size={12} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
</button> : null
|
||||
)}
|
||||
|
||||
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||
@@ -431,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { 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"
|
||||
/>
|
||||
@@ -443,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { 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"
|
||||
/>
|
||||
@@ -549,8 +563,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
// Reload
|
||||
accommodationsApi.list(tripId).then(d => {
|
||||
setAccommodations(d.accommodations || [])
|
||||
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
const all = d.accommodations || []
|
||||
setAccommodations(all)
|
||||
setDayAccommodations(all.filter(a =>
|
||||
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
|
||||
))
|
||||
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
setAccommodation(acc || null)
|
||||
})
|
||||
onAccommodationChange?.()
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
@@ -12,10 +12,13 @@ import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||
@@ -76,9 +79,14 @@ interface DayPlanSidebarProps {
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
onNavigateToFiles?: () => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
canUndo?: boolean
|
||||
lastActionLabel?: string | null
|
||||
onUndo?: () => void
|
||||
}
|
||||
|
||||
export default function DayPlanSidebar({
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
tripId,
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
@@ -88,12 +96,19 @@ export default function DayPlanSidebar({
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
onNavigateToFiles,
|
||||
onExpandedDaysChange,
|
||||
pushUndo,
|
||||
canUndo = false,
|
||||
lastActionLabel = null,
|
||||
onUndo,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const tripStore = useTripStore()
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
|
||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||
|
||||
@@ -104,6 +119,7 @@ export default function DayPlanSidebar({
|
||||
} catch {}
|
||||
return new Set(days.map(d => d.id))
|
||||
})
|
||||
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
|
||||
const [editingDayId, setEditingDayId] = useState(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
@@ -111,12 +127,16 @@ export default function DayPlanSidebar({
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [pdfHover, setPdfHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
const [timeConfirm, setTimeConfirm] = useState<{
|
||||
dayId: number; fromId: number; time: string;
|
||||
// For drag & drop reorder
|
||||
@@ -192,13 +212,67 @@ export default function DayPlanSidebar({
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
// Determine if a reservation's end_time represents a different date (multi-day)
|
||||
const getEndDate = (r: Reservation) => {
|
||||
const endStr = r.reservation_end_time || ''
|
||||
return endStr.includes('T') ? endStr.split('T')[0] : null
|
||||
}
|
||||
|
||||
// Get span phase: how a reservation relates to a specific day's date
|
||||
const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => {
|
||||
if (!r.reservation_time) return 'single'
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r) || startDate
|
||||
if (startDate === endDate) return 'single'
|
||||
if (dayDate === startDate) return 'start'
|
||||
if (dayDate === endDate) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
// Get the appropriate display time for a reservation on a specific day
|
||||
const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => {
|
||||
const phase = getSpanPhase(r, dayDate)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
// Get phase label for multi-day badge
|
||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
if (r.type === 'flight') return t(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||
if (r.type === 'car') return t(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
return reservations.filter(r => {
|
||||
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||
const resDate = r.reservation_time.split('T')[0]
|
||||
return resDate === day.date
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
|
||||
if (endDate && endDate !== startDate) {
|
||||
// Multi-day: show on any day in range (car middle handled elsewhere)
|
||||
return day.date >= startDate && day.date <= endDate
|
||||
} else {
|
||||
// Single-day: show all non-hotel reservations that match this day's date
|
||||
return startDate === day.date
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||
const getActiveRentalsForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
return reservations.filter(r => {
|
||||
if (r.type !== 'car' || !r.reservation_time) return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
if (!endDate || endDate === startDate) return false
|
||||
return day.date > startDate && day.date < endDate
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,47 +334,45 @@ export default function DayPlanSidebar({
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
const dayDate = days.find(d => d.id === dayId)?.date || ''
|
||||
|
||||
// Initialize positions for transports that don't have one yet
|
||||
if (transport.some(r => r.day_plan_position == null)) {
|
||||
initTransportPositions(dayId)
|
||||
}
|
||||
|
||||
// Build base list: untimed places + notes sorted by order_index/sort_order
|
||||
const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null)
|
||||
const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null)
|
||||
|
||||
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order
|
||||
// Places keep their order_index ordering — only transports are inserted based on time.
|
||||
const baseItems = [
|
||||
...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
|
||||
// Timed places + transports: compute sortKeys based on time, inserted among base items
|
||||
const allTimed = [
|
||||
...timedPlaces.map(a => ({ type: 'place' as const, data: a, minutes: parseTimeToMinutes(a.place?.place_time)! })),
|
||||
...transport.map(r => ({ type: 'transport' as const, data: r, minutes: parseTimeToMinutes(r.reservation_time) ?? 0 })),
|
||||
].sort((a, b) => a.minutes - b.minutes)
|
||||
// Only transports are inserted among base items based on time/position
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (allTimed.length === 0) return baseItems
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
if (baseItems.length === 0) {
|
||||
return allTimed.map((item, i) => ({ ...item, sortKey: i }))
|
||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
||||
}
|
||||
|
||||
// Insert timed items among base items using time-to-position mapping.
|
||||
// Each timed item finds the last base place whose order_index corresponds
|
||||
// to a reasonable position, then gets a fractional sortKey after it.
|
||||
// Insert transports among base items using persisted position or time-to-position mapping.
|
||||
const result = [...baseItems]
|
||||
for (let ti = 0; ti < allTimed.length; ti++) {
|
||||
const timed = allTimed[ti]
|
||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||
const timed = timedTransports[ti]
|
||||
const minutes = timed.minutes
|
||||
|
||||
// For transports, use persisted position if available
|
||||
if (timed.type === 'transport' && timed.data.day_plan_position != null) {
|
||||
// Use persisted position if available
|
||||
if (timed.data.day_plan_position != null) {
|
||||
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data })
|
||||
continue
|
||||
}
|
||||
|
||||
// Find insertion position: after the last base item with time <= this item's time
|
||||
// Find insertion position: after the last base item with time <= this transport's time
|
||||
let insertAfterKey = -Infinity
|
||||
for (const item of result) {
|
||||
if (item.type === 'place') {
|
||||
@@ -323,6 +395,16 @@ export default function DayPlanSidebar({
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const mergedItemsMap = useMemo(() => {
|
||||
const map: Record<number, ReturnType<typeof getMergedItems>> = {}
|
||||
days.forEach(day => { map[day.id] = getMergedItems(day.id) })
|
||||
return map
|
||||
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
_openAddNote(dayId, getMergedItems, (id) => {
|
||||
@@ -377,6 +459,9 @@ export default function DayPlanSidebar({
|
||||
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
// Capture previous place order for undo
|
||||
const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id)
|
||||
|
||||
// Places get sequential integer positions (0, 1, 2, ...)
|
||||
// Non-place items between place N-1 and place N get fractional positions
|
||||
const assignmentIds: number[] = []
|
||||
@@ -410,15 +495,23 @@ export default function DayPlanSidebar({
|
||||
try {
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
for (const n of noteUpdates) {
|
||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
if (transportUpdates.length) {
|
||||
for (const tu of transportUpdates) {
|
||||
const res = reservations.find(r => r.id === tu.id)
|
||||
if (res) res.day_plan_position = tu.day_plan_position
|
||||
}
|
||||
setTransportPosVersion(v => v + 1)
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates)
|
||||
}
|
||||
if (prevAssignmentIds.length) {
|
||||
const capturedDayId = dayId
|
||||
const capturedPrevIds = prevAssignmentIds
|
||||
pushUndo?.(t('undo.reorder'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}
|
||||
|
||||
@@ -503,7 +596,7 @@ export default function DayPlanSidebar({
|
||||
currentAssignments[key] = currentAssignments[key].map(a =>
|
||||
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
|
||||
)
|
||||
tripStore.setAssignments(currentAssignments)
|
||||
tripActions.setAssignments(currentAssignments)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
||||
@@ -581,12 +674,14 @@ export default function DayPlanSidebar({
|
||||
}
|
||||
|
||||
const toggleLock = (assignmentId) => {
|
||||
const prevLocked = new Set(lockedIds)
|
||||
setLockedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(assignmentId)) next.delete(assignmentId)
|
||||
else next.add(assignmentId)
|
||||
return next
|
||||
})
|
||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||
}
|
||||
|
||||
const handleOptimize = async () => {
|
||||
@@ -594,6 +689,8 @@ export default function DayPlanSidebar({
|
||||
const da = getDayAssignments(selectedDayId)
|
||||
if (da.length < 3) return
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
|
||||
// Separate locked (stay at their index) and unlocked assignments
|
||||
const locked = new Map() // index -> assignment
|
||||
const unlocked = []
|
||||
@@ -620,6 +717,10 @@ export default function DayPlanSidebar({
|
||||
|
||||
await onReorder(selectedDayId, result.map(a => a.id))
|
||||
toast.success(t('dayplan.toast.routeOptimized'))
|
||||
const capturedDayId = selectedDayId
|
||||
pushUndo?.(t('undo.optimize'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||
})
|
||||
}
|
||||
|
||||
const handleGoogleMaps = () => {
|
||||
@@ -632,14 +733,24 @@ export default function DayPlanSidebar({
|
||||
|
||||
const handleDropOnDay = (e, dayId) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOverDayId(null)
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId))
|
||||
const capturedFromDayId = fromDayId
|
||||
const capturedOrderIndex = srcAssignment?.order_index ?? 0
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId)
|
||||
.then(() => {
|
||||
pushUndo?.(t('undo.moveDay'), async () => {
|
||||
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId && fromDayId !== dayId) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
@@ -668,10 +779,10 @@ export default function DayPlanSidebar({
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const totalCost = days.reduce((s, d) => {
|
||||
const totalCost = useMemo(() => days.reduce((s, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
||||
}, 0)
|
||||
}, 0), [days, assignments])
|
||||
|
||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
||||
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
||||
@@ -686,62 +797,124 @@ export default function DayPlanSidebar({
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' – ')}
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' – ')}
|
||||
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
title={t('dayplan.pdfTooltip')}
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('ICS export failed') }
|
||||
}}
|
||||
title={t('dayplan.icsTooltip')}
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setPdfHover(true)}
|
||||
onMouseLeave={() => setPdfHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
</button>
|
||||
{pdfHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{t('dayplan.pdfTooltip')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('ICS export failed') }
|
||||
}}
|
||||
onMouseEnter={() => setIcsHover(true)}
|
||||
onMouseLeave={() => setIcsHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
{icsHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{t('dayplan.icsTooltip')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onUndo && (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
onMouseEnter={() => setUndoHover(true)}
|
||||
onMouseLeave={() => setUndoHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: canUndo ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
cursor: canUndo ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
transition: 'color 0.15s, border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<Undo2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{undoHover && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px',
|
||||
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{canUndo && lastActionLabel ? t('undo.tooltip', { action: lastActionLabel }) : t('undo.button')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -755,7 +928,7 @@ export default function DayPlanSidebar({
|
||||
const formattedDate = formatDate(day.date, locale)
|
||||
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
||||
const isDragTarget = dragOverDayId === day.id
|
||||
const merged = getMergedItems(day.id)
|
||||
const merged = mergedItemsMap[day.id] || []
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
|
||||
@@ -777,6 +950,7 @@ export default function DayPlanSidebar({
|
||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||
outlineOffset: -2,
|
||||
borderRadius: isDragTarget ? 8 : 0,
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||
@@ -810,15 +984,15 @@ export default function DayPlanSidebar({
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
<button
|
||||
{canEditDays && <button
|
||||
onClick={e => startEditTitle(day, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>}
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
@@ -848,6 +1022,17 @@ export default function DayPlanSidebar({
|
||||
)
|
||||
})
|
||||
})()}
|
||||
{/* Active rental car badges */}
|
||||
{(() => {
|
||||
const activeRentals = getActiveRentalsForDay(day.id)
|
||||
if (activeRentals.length === 0) return null
|
||||
return activeRentals.map(r => (
|
||||
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
|
||||
<Car size={8} style={{ color: '#3b82f6', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
</span>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
||||
@@ -862,20 +1047,20 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
{canEditDays && <button
|
||||
onClick={e => openAddNote(day.id, e)}
|
||||
title={t('dayplan.addNote')}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>
|
||||
<FileText size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<FileText size={16} strokeWidth={2} />
|
||||
</button>}
|
||||
<button
|
||||
onClick={e => toggleDay(day.id, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
|
||||
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -886,21 +1071,24 @@ export default function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
||||
if (dropTargetRef.current?.startsWith('transport-')) {
|
||||
const transportId = Number(dropTargetRef.current.replace('transport-', ''))
|
||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
||||
const transportId = Number(parts[0])
|
||||
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (assignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
return
|
||||
@@ -912,11 +1100,11 @@ export default function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -941,8 +1129,9 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}`
|
||||
|
||||
if (item.type === 'place') {
|
||||
const assignment = item.data
|
||||
@@ -992,8 +1181,9 @@ export default function DayPlanSidebar({
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
||||
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -1010,7 +1200,7 @@ export default function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
@@ -1018,7 +1208,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
@@ -1027,12 +1217,12 @@ export default function DayPlanSidebar({
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
@@ -1050,9 +1240,9 @@ export default function DayPlanSidebar({
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
</div>}
|
||||
<div
|
||||
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
|
||||
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
|
||||
@@ -1103,10 +1293,8 @@ export default function DayPlanSidebar({
|
||||
)}
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
<div className="collab-note-md" style={{ marginTop: 2, fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
@@ -1155,14 +1343,14 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -1171,6 +1359,11 @@ export default function DayPlanSidebar({
|
||||
// Transport booking (flight, train, bus, car, cruise)
|
||||
if (item.type === 'transport') {
|
||||
const res = item.data
|
||||
const spanPhase = getSpanPhase(res, day.date)
|
||||
|
||||
// Car "active" (middle) days are shown in the day header, skip here
|
||||
if (res.type === 'car' && spanPhase === 'middle') return null
|
||||
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const color = '#3b82f6'
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
@@ -1187,25 +1380,37 @@ export default function DayPlanSidebar({
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
// Multi-day span phase
|
||||
const spanLabel = getSpanLabel(res, spanPhase)
|
||||
const displayTime = getDisplayTimeForDay(res, day.date)
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}`}>
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
onClick={() => setTransportDetail(res)}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`transport-${res.id}`) }}
|
||||
onDragOver={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const inBottom = e.clientY > rect.top + rect.height / 2
|
||||
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const insertAfter = e.clientY > rect.top + rect.height / 2
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
}}
|
||||
@@ -1220,6 +1425,7 @@ export default function DayPlanSidebar({
|
||||
background: isTransportHovered ? `${color}12` : `${color}08`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
transition: 'background 0.1s',
|
||||
opacity: spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
@@ -1230,14 +1436,27 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{spanLabel && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4, flexShrink: 0,
|
||||
background: `${color}20`, color: color, textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}>
|
||||
{spanLabel}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{res.title}
|
||||
</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
{displayTime?.includes('T') && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{spanPhase === 'single' && res.reservation_end_time && (() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
|
||||
})()}
|
||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1248,6 +1467,7 @@ export default function DayPlanSidebar({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1261,8 +1481,8 @@ export default function DayPlanSidebar({
|
||||
<React.Fragment key={`note-${note.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
@@ -1272,7 +1492,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
@@ -1280,17 +1500,17 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
|
||||
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
])}
|
||||
]) : undefined}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
@@ -1304,9 +1524,9 @@ export default function DayPlanSidebar({
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
|
||||
</div>
|
||||
@@ -1315,17 +1535,17 @@ export default function DayPlanSidebar({
|
||||
{note.text}
|
||||
</span>
|
||||
{note.time && (
|
||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
|
||||
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -1345,11 +1565,11 @@ export default function DayPlanSidebar({
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -1406,7 +1626,7 @@ export default function DayPlanSidebar({
|
||||
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
|
||||
<div key={dayId} style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => cancelNote(Number(dayId))}>
|
||||
@@ -1423,8 +1643,8 @@ export default function DayPlanSidebar({
|
||||
{NOTE_ICONS.map(({ id, Icon }) => (
|
||||
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
|
||||
title={id}
|
||||
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
||||
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
|
||||
style={{ width: 45, height: 45, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
||||
<Icon size={18} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1434,8 +1654,9 @@ export default function DayPlanSidebar({
|
||||
value={ui.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
placeholder={t('dayplan.noteTitle')}
|
||||
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
placeholder={t('dayplan.noteTitle') + ' *'}
|
||||
required
|
||||
style={{ fontSize: 13, fontWeight: 500, border: `1px solid ${!ui.text?.trim() ? 'var(--border-primary)' : 'var(--border-primary)'}`, borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<textarea
|
||||
value={ui.time}
|
||||
@@ -1446,10 +1667,10 @@ export default function DayPlanSidebar({
|
||||
placeholder={t('dayplan.noteSubtitle')}
|
||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)', resize: 'none', lineHeight: 1.4 }}
|
||||
/>
|
||||
<div style={{ textAlign: 'right', fontSize: 9, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<div style={{ textAlign: 'right', fontSize: 11, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => cancelNote(Number(dayId))} 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={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
|
||||
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} style={{ fontSize: 12, background: !ui.text?.trim() ? 'var(--border-primary)' : 'var(--accent)', color: !ui.text?.trim() ? 'var(--text-faint)' : 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||
{ui.mode === 'add' ? t('common.add') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1550,7 +1771,7 @@ export default function DayPlanSidebar({
|
||||
{res.reservation_time?.includes('T')
|
||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
: res.reservation_time
|
||||
? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: ''
|
||||
}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
@@ -1594,13 +1815,13 @@ export default function DayPlanSidebar({
|
||||
{res.notes && (
|
||||
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{res.notes}</div>
|
||||
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dateien */}
|
||||
{(() => {
|
||||
const resFiles = (tripStore.files || []).filter(f =>
|
||||
const resFiles = (useTripStore.getState().files || []).filter(f =>
|
||||
!f.deleted_at && (
|
||||
f.reservation_id === res.id ||
|
||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
|
||||
@@ -1661,4 +1882,6 @@ export default function DayPlanSidebar({
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default DayPlanSidebar
|
||||
|
||||
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -66,6 +68,9 @@ export default function PlaceFormModal({
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { hasMapsKey } = useAuthStore()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canUploadFiles = can('file_upload', tripObj)
|
||||
|
||||
useEffect(() => {
|
||||
if (place) {
|
||||
@@ -171,6 +176,7 @@ export default function PlaceFormModal({
|
||||
|
||||
// Paste support for files/images
|
||||
const handlePaste = (e) => {
|
||||
if (!canUploadFiles) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -386,7 +392,7 @@ export default function PlaceFormModal({
|
||||
</div>
|
||||
|
||||
{/* File Attachments */}
|
||||
{true && (
|
||||
{canUploadFiles && (
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react'
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -116,7 +119,7 @@ interface PlaceInspectorProps {
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
files: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
tripMembers?: TripMember[]
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||
@@ -339,10 +342,8 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || place.notes || googleDetails?.summary) && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
{place.description || place.notes || googleDetails?.summary}
|
||||
</p>
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,7 +373,7 @@ export default function PlaceInspector({
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time?.includes('T') && (
|
||||
@@ -391,7 +392,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
|
||||
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
@@ -461,6 +462,98 @@ export default function PlaceInspector({
|
||||
)}
|
||||
|
||||
|
||||
{/* GPX Track stats */}
|
||||
{place.route_geometry && (() => {
|
||||
try {
|
||||
const pts: number[][] = JSON.parse(place.route_geometry)
|
||||
if (!pts || pts.length < 2) return null
|
||||
const hasEle = pts[0].length >= 3
|
||||
|
||||
// Haversine distance
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
let totalDist = 0
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
|
||||
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
|
||||
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
}
|
||||
const distKm = totalDist / 1000
|
||||
|
||||
// Elevation stats
|
||||
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
|
||||
if (hasEle) {
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const e = pts[i][2]
|
||||
if (e < minEle) minEle = e
|
||||
if (e > maxEle) maxEle = e
|
||||
if (i > 0) {
|
||||
const diff = e - pts[i - 1][2]
|
||||
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Elevation profile SVG
|
||||
const chartW = 280, chartH = 60
|
||||
const elevations = hasEle ? pts.map(p => p[2]) : []
|
||||
let pathD = ''
|
||||
if (elevations.length > 1) {
|
||||
const step = Math.max(1, Math.floor(elevations.length / chartW))
|
||||
const sampled = elevations.filter((_, i) => i % step === 0)
|
||||
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
|
||||
const range = eMax - eMin || 1
|
||||
pathD = sampled.map((e, i) => {
|
||||
const x = (i / (sampled.length - 1)) * chartW
|
||||
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<TrendingUp size={13} color="#9ca3af" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<MapPin size={12} color="#3b82f6" />
|
||||
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
|
||||
</div>
|
||||
{hasEle && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<Mountain size={12} color="#22c55e" />
|
||||
{Math.round(maxEle)} m
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
|
||||
<Mountain size={12} color="#ef4444" />
|
||||
{Math.round(minEle)} m
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
↑{Math.round(totalUp)} m ↓{Math.round(totalDown)} m
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{pathD && (
|
||||
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
|
||||
<defs>
|
||||
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
|
||||
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} catch { return null }
|
||||
})()}
|
||||
|
||||
{/* Files section */}
|
||||
{(placeFiles.length > 0 || onFileUpload) && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
@@ -489,11 +582,11 @@ export default function PlaceInspector({
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{placeFiles.map(f => (
|
||||
<a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<button key={f.id} onClick={async () => { const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
|
||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||
</a>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -11,6 +11,7 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
@@ -28,17 +29,21 @@ interface PlacesSidebarProps {
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryId: string) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, pushUndo,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||
const tripStore = useTripStore()
|
||||
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]
|
||||
@@ -46,12 +51,51 @@ export default function PlacesSidebar({
|
||||
e.target.value = ''
|
||||
try {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
await tripStore.loadTrip(tripId)
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('places.gpxImported', { count: result.count }))
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGpx'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||
}
|
||||
}
|
||||
|
||||
const [googleListOpen, setGoogleListOpen] = useState(false)
|
||||
const [googleListUrl, setGoogleListUrl] = useState('')
|
||||
const [googleListLoading, setGoogleListLoading] = useState(false)
|
||||
|
||||
const handleGoogleListImport = async () => {
|
||||
if (!googleListUrl.trim()) return
|
||||
setGoogleListLoading(true)
|
||||
try {
|
||||
const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
|
||||
setGoogleListOpen(false)
|
||||
setGoogleListUrl('')
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGoogleList'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.googleListError'))
|
||||
} finally {
|
||||
setGoogleListLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||
@@ -67,11 +111,12 @@ export default function PlacesSidebar({
|
||||
}
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const plannedIds = new Set(
|
||||
const plannedIds = useMemo(() => new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
)
|
||||
), [assignments])
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
@@ -79,7 +124,7 @@ export default function PlacesSidebar({
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
}), [places, filter, categoryFilters, search, plannedIds.size])
|
||||
}), [places, filter, categoryFilters, search, plannedIds])
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
@@ -88,7 +133,7 @@ export default function PlacesSidebar({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Kopfbereich */}
|
||||
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<button
|
||||
{canEditPlaces && <button
|
||||
onClick={onAddPlace}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
@@ -98,20 +143,36 @@ export default function PlacesSidebar({
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>
|
||||
</button>}
|
||||
{canEditPlaces && <>
|
||||
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
|
||||
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>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGoogleListOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')}
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
@@ -223,9 +284,9 @@ export default function PlacesSidebar({
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
</span>
|
||||
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{canEditPlaces && <button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('places.addPlace')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(place => {
|
||||
@@ -245,19 +306,19 @@ export default function PlacesSidebar({
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMobile && days?.length > 0) {
|
||||
if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
@@ -312,49 +373,133 @@ export default function PlacesSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
|
||||
{dayPickerPlace && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => setDayPickerPlace(null)}
|
||||
onClick={() => { setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '60vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{t('places.assignToDay')}</div>
|
||||
{dayPickerPlace.address && <div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{dayPickerPlace.address}</div>}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
||||
{days.map((day, i) => {
|
||||
return (
|
||||
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
|
||||
{/* View details */}
|
||||
<button
|
||||
onClick={() => { onPlaceClick(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Eye size={18} color="var(--text-muted)" /> {t('places.viewDetails')}
|
||||
</button>
|
||||
{/* Edit */}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
onClick={() => { onEditPlace(dayPickerPlace); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Pencil size={18} color="var(--text-muted)" /> {t('common.edit')}
|
||||
</button>
|
||||
)}
|
||||
{/* Assign to day */}
|
||||
{days?.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => setMobileShowDays(v => !v)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0,
|
||||
}}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{day.title || `${t('dayplan.dayN', { n: i + 1 })}`}
|
||||
</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
||||
<CalendarDays size={18} color="var(--text-muted)" /> {t('places.assignToDay')}
|
||||
<ChevronDown size={14} style={{ marginLeft: 'auto', color: 'var(--text-faint)', transform: mobileShowDays ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{mobileShowDays && (
|
||||
<div style={{ paddingLeft: 20 }}>
|
||||
{days.map((day, i) => (
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 14px', borderRadius: 10, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0 }}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Delete */}
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
onClick={() => { onDeletePlace(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: '#ef4444' }}
|
||||
>
|
||||
<Trash2 size={18} /> {t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{googleListOpen && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
|
||||
{t('places.importGoogleList')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
|
||||
{t('places.googleListHint')}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={googleListUrl}
|
||||
onChange={e => setGoogleListUrl(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
|
||||
placeholder="https://maps.app.goo.gl/..."
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', padding: '10px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)',
|
||||
fontSize: 13, color: 'var(--text-primary)', outline: 'none',
|
||||
fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoogleListImport}
|
||||
disabled={!googleListUrl.trim() || googleListLoading}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{googleListLoading ? t('common.loading') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -363,4 +508,6 @@ export default function PlacesSidebar({
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default PlacesSidebar
|
||||
|
||||
@@ -59,7 +59,7 @@ interface ReservationModalProps {
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
files?: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
}
|
||||
@@ -71,11 +71,20 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const { t, locale } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
@@ -95,12 +104,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
useEffect(() => {
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
// Parse end_date from reservation_end_time if it's a full ISO datetime
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
if (rawEnd.includes('T')) {
|
||||
endDate = rawEnd.split('T')[0]
|
||||
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
|
||||
}
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
type: reservation.type || 'other',
|
||||
status: reservation.status || 'pending',
|
||||
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
|
||||
reservation_end_time: reservation.reservation_end_time || '',
|
||||
reservation_end_time: endTime,
|
||||
end_date: endDate,
|
||||
location: reservation.location || '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
@@ -110,6 +128,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_departure_airport: meta.departure_airport || '',
|
||||
meta_arrival_airport: meta.arrival_airport || '',
|
||||
meta_departure_timezone: meta.departure_timezone || '',
|
||||
meta_arrival_timezone: meta.arrival_timezone || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
@@ -118,13 +138,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||
price: meta.price || '',
|
||||
budget_category: meta.budget_category || '',
|
||||
})
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_out_time: '',
|
||||
})
|
||||
@@ -134,9 +158,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Validate that end datetime is after start datetime
|
||||
const isEndBeforeStart = (() => {
|
||||
if (!form.end_date || !form.reservation_time) return false
|
||||
const startDate = form.reservation_time.split('T')[0]
|
||||
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
||||
const endTime = form.reservation_end_time || '00:00'
|
||||
const startFull = `${startDate}T${startTime}`
|
||||
const endFull = `${form.end_date}T${endTime}`
|
||||
return endFull <= startFull
|
||||
})()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const metadata: Record<string, string> = {}
|
||||
@@ -145,6 +181,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
|
||||
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
|
||||
if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone
|
||||
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
|
||||
} else if (form.type === 'hotel') {
|
||||
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
|
||||
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
|
||||
@@ -153,15 +191,26 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
// Combine end_date + end_time into reservation_end_time
|
||||
let combinedEndTime = form.reservation_end_time
|
||||
if (form.end_date) {
|
||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||
}
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
const saveData: Record<string, any> = {
|
||||
title: form.title, type: form.type, status: form.status,
|
||||
reservation_time: form.reservation_time, reservation_end_time: form.reservation_end_time,
|
||||
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
|
||||
location: form.location, confirmation_number: form.confirmation_number,
|
||||
notes: form.notes,
|
||||
assignment_id: form.assignment_id || null,
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
}
|
||||
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
// If hotel with place + days, pass hotel data for auto-creation or update
|
||||
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
@@ -257,10 +306,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* Assignment Picker + Date (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{assignmentOptions.length > 0 && (
|
||||
{/* Assignment Picker (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && assignmentOptions.length > 0 && (
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||
@@ -287,54 +335,81 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, t] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Time + End Time + Status */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{form.type !== 'hotel' && (
|
||||
<>
|
||||
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, t] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const date = d || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{form.type === 'flight' && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const date = d || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<input type="text" value={form.meta_departure_timezone} onChange={e => set('meta_departure_timezone', e.target.value)}
|
||||
placeholder="e.g. CET, UTC+1" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={form.end_date}
|
||||
onChange={d => set('end_date', d || '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<input type="text" value={form.meta_arrival_timezone} onChange={e => set('meta_arrival_timezone', e.target.value)}
|
||||
placeholder="e.g. JST, UTC+9" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEndBeforeStart && (
|
||||
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Location + Booking Code */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@@ -422,8 +497,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Check-in/out times */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Check-in/out times + Status */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
|
||||
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
|
||||
@@ -432,6 +507,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
|
||||
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -504,14 +591,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
</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' }}>
|
||||
@@ -556,12 +643,41 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
|
||||
placeholder="0.00"
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -572,6 +688,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -56,9 +57,10 @@ interface ReservationCardProps {
|
||||
files?: TripFile[]
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -82,8 +84,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
}
|
||||
|
||||
const fmtDate = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
@@ -95,24 +97,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{/* Header bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
@@ -124,14 +136,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{r.reservation_time && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time?.includes('T') && r.reservation_end_time.split('T')[0] !== r.reservation_time.split('T')[0] && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time}` : ''}
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -167,8 +184,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: meta.check_in_time })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: meta.check_out_time })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||
@@ -330,6 +347,9 @@ interface ReservationsPanelProps {
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('reservation_edit', trip)
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||
@@ -348,13 +368,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -370,14 +392,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react'
|
||||
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from './Section'
|
||||
|
||||
interface Props {
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<style>{`
|
||||
@keyframes heartPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
`}</style>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', marginBottom: 6, marginTop: -4 }}>
|
||||
{t('settings.about.description')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, lineHeight: 1.6, color: 'var(--text-faint)', marginBottom: 16 }}>
|
||||
{t('settings.about.madeWith')}{' '}
|
||||
<Heart size={11} fill="#991b1b" stroke="#991b1b" style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }} />
|
||||
{' '}{t('settings.about.madeBy')}{' '}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', verticalAlign: '1px' }}>v{appVersion}</span>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} style={{ color: '#ffdd00' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nSdKaXgN"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi, adminApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { UserWithOidc } from '../../types'
|
||||
import Section from './Section'
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
|
||||
export default function AccountTab(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
|
||||
// Profile
|
||||
const [username, setUsername] = useState<string>(user?.username || '')
|
||||
const [email, setEmail] = useState<string>(user?.email || '')
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '')
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
// Password
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [oidcOnlyMode, setOidcOnlyMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then(config => {
|
||||
if (config?.oidc_only_mode) setOidcOnlyMode(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// MFA
|
||||
const [mfaQr, setMfaQr] = useState<string | null>(null)
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('')
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||
const [mfaLoading, setMfaLoading] = useState(false)
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
|
||||
const mfaRequiredByPolicy =
|
||||
!demoMode &&
|
||||
!user?.mfa_enabled &&
|
||||
(searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||
|
||||
const backupCodesText = backupCodes?.join('\n') || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.mfa_enabled || backupCodes) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => typeof x === 'string')) {
|
||||
setBackupCodes(parsed)
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
}
|
||||
}, [user?.mfa_enabled, backupCodes])
|
||||
|
||||
const dismissBackupCodes = () => {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
}
|
||||
|
||||
const copyBackupCodes = async () => {
|
||||
if (!backupCodesText) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesText)
|
||||
toast.success(t('settings.mfa.backupCopied'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'trek-mfa-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const printBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
|
||||
const w = window.open('', '_blank', 'width=900,height=700')
|
||||
if (!w) return
|
||||
w.document.open()
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.focus()
|
||||
w.print()
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
toast.success(t('settings.avatarUploaded'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
{!oidcOnlyMode && (
|
||||
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder={t('settings.newPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('settings.confirmPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA */}
|
||||
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div className="flex gap-3 p-3 rounded-lg border text-sm"
|
||||
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||
{demoMode ? (
|
||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
|
||||
{!user?.mfa_enabled && !mfaQr && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const data = await authApi.mfaSetup() as { qr_svg: string; secret: string }
|
||||
setMfaQr(data.qr_svg)
|
||||
setMfaSecret(data.secret)
|
||||
setMfaSetupCode('')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('settings.mfa.setup')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!user?.mfa_enabled && mfaQr && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
|
||||
<div className="rounded-lg border mx-auto block overflow-hidden" style={{ width: 'fit-content', borderColor: 'var(--border-primary)' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
|
||||
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaSetupCode}
|
||||
onChange={e => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || mfaSetupCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
|
||||
toast.success(t('settings.mfa.toastEnabled'))
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
const codes = resp.backup_codes || null
|
||||
if (codes?.length) {
|
||||
try { sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes)) } catch { /* ignore */ }
|
||||
}
|
||||
setBackupCodes(codes)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.enable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
className="px-4 py-2 rounded-lg text-sm border"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.mfa_enabled && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={mfaDisablePwd}
|
||||
onChange={e => setMfaDisablePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
|
||||
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
|
||||
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
|
||||
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||
</button>
|
||||
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Download size={13} /> {t('settings.mfa.backupDownload')}
|
||||
</button>
|
||||
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Printer size={13} /> {t('settings.mfa.backupPrint')}
|
||||
</button>
|
||||
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24, fontWeight: 700,
|
||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} style={{ display: 'none' }} />
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
style={{
|
||||
position: 'absolute', bottom: -3, right: -3,
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
{user?.avatar_url && (
|
||||
<button
|
||||
onClick={handleAvatarRemove}
|
||||
style={{
|
||||
position: 'absolute', top: -2, right: -2,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: '#ef4444', color: 'white',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
|
||||
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
||||
</span>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
|
||||
background: '#dbeafe', color: '#1d4ed8', marginLeft: 6,
|
||||
}}>
|
||||
SSO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
|
||||
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
|
||||
<span className="sm:hidden">{t('common.save')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
|
||||
style={{ border: '1px solid #fecaca' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
|
||||
<span className="sm:hidden">{t('common.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} style={{ color: '#d97706' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteBlockedMessage')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.ok') || 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirm */}
|
||||
{showDeleteConfirm === true && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteAccountWarning')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||
border: 'none', background: '#ef4444', color: 'white',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('settings.deleteAccountConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from './Section'
|
||||
|
||||
export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
|
||||
useEffect(() => {
|
||||
setTempUnit(settings.temperature_unit || 'celsius')
|
||||
}, [settings.temperature_unit])
|
||||
|
||||
return (
|
||||
<Section title={t('settings.display')} icon={Palette}>
|
||||
{/* Color Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
|
||||
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ value: 'light', label: t('settings.light'), icon: Sun },
|
||||
{ value: 'dark', label: t('settings.dark'), icon: Moon },
|
||||
{ value: 'auto', label: t('settings.auto'), icon: Monitor },
|
||||
].map(opt => {
|
||||
const current = settings.dark_mode
|
||||
const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 14px', borderRadius: 10, cursor: 'pointer', flex: '1 1 0', justifyContent: 'center', minWidth: 0,
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.temperature')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Format */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('blur_booking_codes', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import Section from './Section'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react'
|
||||
import { authApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
|
||||
|
||||
interface McpToken {
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
}, [loadAddons])
|
||||
|
||||
// MCP state
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [mcpNewName, setMcpNewName] = useState('')
|
||||
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
|
||||
const [mcpCreating, setMcpCreating] = useState(false)
|
||||
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
|
||||
const mcpEndpoint = `${window.location.origin}/mcp`
|
||||
const mcpJsonConfig = `{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${mcpEndpoint}",
|
||||
"--header",
|
||||
"Authorization: Bearer <your_token>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
useEffect(() => {
|
||||
if (mcpEnabled) {
|
||||
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
|
||||
}
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateMcpToken = async () => {
|
||||
if (!mcpNewName.trim()) return
|
||||
setMcpCreating(true)
|
||||
try {
|
||||
const d = await authApi.mcpTokens.create(mcpNewName.trim())
|
||||
setMcpCreatedToken(d.token.raw_token)
|
||||
setMcpNewName('')
|
||||
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.createError'))
|
||||
} finally {
|
||||
setMcpCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMcpToken = async (id: number) => {
|
||||
try {
|
||||
await authApi.mcpTokens.delete(id)
|
||||
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setMcpDeleteId(null)
|
||||
toast.success(t('settings.mcp.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
{mcpEnabled && (
|
||||
<Section title={t('settings.mcp.title')} icon={Terminal}>
|
||||
{/* Endpoint URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.endpoint')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpEndpoint}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON config box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
|
||||
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpJsonConfig}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpTokens.length === 0 ? (
|
||||
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Create MCP Token modal */}
|
||||
{mcpModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) setMcpModalOpen(false) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
{!mcpCreatedToken ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createTitle')}</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.tokenName')}</label>
|
||||
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button onClick={() => setMcpModalOpen(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createdTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.createdWarning')}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpCreatedToken}
|
||||
</pre>
|
||||
<button onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
|
||||
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete MCP Token confirm */}
|
||||
{mcpDeleteId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.deleteTokenTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setMcpDeleteId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteMcpToken(mcpDeleteId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.mcp.deleteTokenTitle')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Map, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import Section from './Section'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
export default function MapSettingsTab(): React.ReactElement {
|
||||
const { settings, updateSettings } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
setDefaultLng(settings.default_lng || 2.3522)
|
||||
setDefaultZoom(settings.default_zoom || 10)
|
||||
}, [settings])
|
||||
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
setDefaultLng(mapInfo.latlng.lng)
|
||||
}, [])
|
||||
|
||||
const mapPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Default map center',
|
||||
description: '',
|
||||
lat: defaultLat as number,
|
||||
lng: defaultLng as number,
|
||||
address: '',
|
||||
category_id: 0,
|
||||
icon: null,
|
||||
price: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: Date(),
|
||||
}], [defaultLat, defaultLng])
|
||||
|
||||
const saveMapSettings = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateSettings({
|
||||
map_tile_url: mapTileUrl,
|
||||
default_lat: parseFloat(String(defaultLat)),
|
||||
default_lng: parseFloat(String(defaultLng)),
|
||||
default_zoom: parseInt(String(defaultZoom)),
|
||||
})
|
||||
toast.success(t('settings.toast.mapSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLat}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.longitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLng}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: handleMapClick,
|
||||
onMapContextMenu: null,
|
||||
center: [settings.default_lat, settings.default_lng],
|
||||
zoom: defaultZoom,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveMap')}
|
||||
</button>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { notificationsApi, settingsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import Section from './Section'
|
||||
|
||||
interface PreferencesMatrix {
|
||||
preferences: Record<string, Record<string, boolean>>
|
||||
available_channels: { email: boolean; webhook: boolean; inapp: boolean }
|
||||
event_types: string[]
|
||||
implemented_combos: Record<string, string[]>
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
}
|
||||
|
||||
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
trip_invite: 'settings.notifyTripInvite',
|
||||
booking_change: 'settings.notifyBookingChange',
|
||||
trip_reminder: 'settings.notifyTripReminder',
|
||||
vacay_invite: 'settings.notifyVacayInvite',
|
||||
photos_shared: 'settings.notifyPhotosShared',
|
||||
collab_message: 'settings.notifyCollabMessage',
|
||||
packing_tagged: 'settings.notifyPackingTagged',
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
}
|
||||
|
||||
export default function NotificationsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [webhookIsSet, setWebhookIsSet] = useState(false)
|
||||
const [webhookSaving, setWebhookSaving] = useState(false)
|
||||
const [webhookTesting, setWebhookTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
||||
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
|
||||
const val = (data.settings?.webhook_url as string) || ''
|
||||
if (val === '••••••••') {
|
||||
setWebhookIsSet(true)
|
||||
setWebhookUrl('')
|
||||
} else {
|
||||
setWebhookUrl(val)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const visibleChannels = matrix
|
||||
? (['email', 'webhook', 'inapp'] as const).filter(ch => {
|
||||
if (!matrix.available_channels[ch]) return false
|
||||
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
|
||||
})
|
||||
: []
|
||||
|
||||
const toggle = async (eventType: string, channel: string) => {
|
||||
if (!matrix) return
|
||||
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||
const updated = {
|
||||
...matrix.preferences,
|
||||
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
|
||||
}
|
||||
setMatrix(m => m ? { ...m, preferences: updated } : m)
|
||||
setSaving(true)
|
||||
try {
|
||||
await notificationsApi.updatePreferences(updated)
|
||||
} catch {
|
||||
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveWebhookUrl = async () => {
|
||||
setWebhookSaving(true)
|
||||
try {
|
||||
await settingsApi.set('webhook_url', webhookUrl)
|
||||
if (webhookUrl) setWebhookIsSet(true)
|
||||
else setWebhookIsSet(false)
|
||||
toast.success(t('settings.webhookUrl.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setWebhookSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testWebhookUrl = async () => {
|
||||
if (!webhookUrl && !webhookIsSet) return
|
||||
setWebhookTesting(true)
|
||||
try {
|
||||
const result = await notificationsApi.testWebhook(webhookUrl || undefined)
|
||||
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
|
||||
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
|
||||
} catch {
|
||||
toast.error(t('settings.webhookUrl.testFailed'))
|
||||
} finally {
|
||||
setWebhookTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading…</p>
|
||||
|
||||
if (visibleChannels.length === 0) {
|
||||
return (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{t('settings.notificationPreferences.noChannels')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||
{matrix.available_channels.webhook && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.webhookUrl.label')}
|
||||
</label>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={webhookUrl}
|
||||
onChange={e => setWebhookUrl(e.target.value)}
|
||||
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
|
||||
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={saveWebhookUrl}
|
||||
disabled={webhookSaving}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={testWebhookUrl}
|
||||
disabled={(!webhookUrl && !webhookIsSet) || webhookTesting}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.test')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t(CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map(eventType => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch))
|
||||
if (relevantChannels.length === 0) return null
|
||||
return (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch)) {
|
||||
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||
return (
|
||||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.notifications')} icon={Lock}>
|
||||
{renderContent()}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Camera, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import apiClient from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Section from './Section'
|
||||
|
||||
interface ProviderField {
|
||||
key: string
|
||||
label: string
|
||||
input_type: string
|
||||
placeholder?: string | null
|
||||
required: boolean
|
||||
secret: boolean
|
||||
settings_key?: string | null
|
||||
payload_key?: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface PhotoProviderAddon {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
fields?: ProviderField[]
|
||||
}
|
||||
|
||||
interface ProviderConfig {
|
||||
settings_get?: string
|
||||
settings_put?: string
|
||||
status_get?: string
|
||||
test_get?: string
|
||||
test_post?: string
|
||||
}
|
||||
|
||||
const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => {
|
||||
const raw = provider.config || {}
|
||||
return {
|
||||
settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined,
|
||||
settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined,
|
||||
status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined,
|
||||
test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined,
|
||||
test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => {
|
||||
return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order)
|
||||
}
|
||||
|
||||
export default function PhotoProvidersSection(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { isEnabled: addonEnabled, addons } = useAddonStore()
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
const [providerValues, setProviderValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [providerConnected, setProviderConnected] = useState<Record<string, boolean>>({})
|
||||
const [providerTesting, setProviderTesting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const activePhotoProviders = useMemo(
|
||||
() => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[],
|
||||
[addons],
|
||||
)
|
||||
|
||||
const buildProviderPayload = (provider: PhotoProviderAddon): Record<string, unknown> => {
|
||||
const values = providerValues[provider.id] || {}
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const field of getProviderFields(provider)) {
|
||||
const payloadKey = field.payload_key || field.settings_key || field.key
|
||||
const value = (values[field.key] || '').trim()
|
||||
if (field.secret && !value) continue
|
||||
payload[payloadKey] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const refreshProviderConnection = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const statusPath = cfg.status_get
|
||||
if (!statusPath) return
|
||||
try {
|
||||
const res = await apiClient.get(statusPath)
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected }))
|
||||
} catch {
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const activeProviderSignature = useMemo(
|
||||
() => activePhotoProviders.map(provider => provider.id).join('|'),
|
||||
[activePhotoProviders],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
for (const provider of activePhotoProviders) {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const fields = getProviderFields(provider)
|
||||
|
||||
if (cfg.settings_get) {
|
||||
apiClient.get(cfg.settings_get).then(res => {
|
||||
if (isCancelled) return
|
||||
|
||||
const nextValues: Record<string, string> = {}
|
||||
for (const field of fields) {
|
||||
// Do not prefill secret fields; user can overwrite only when needed.
|
||||
if (field.secret) continue
|
||||
const sourceKey = field.settings_key || field.payload_key || field.key
|
||||
const rawValue = (res.data as Record<string, unknown>)[sourceKey]
|
||||
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : ''
|
||||
}
|
||||
setProviderValues(prev => ({
|
||||
...prev,
|
||||
[provider.id]: { ...(prev[provider.id] || {}), ...nextValues },
|
||||
}))
|
||||
if (typeof res.data?.connected === 'boolean') {
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected }))
|
||||
}
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
refreshProviderConnection(provider).catch(() => { })
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [activePhotoProviders, activeProviderSignature])
|
||||
|
||||
const handleProviderFieldChange = (providerId: string, key: string, value: string) => {
|
||||
setProviderValues(prev => ({
|
||||
...prev,
|
||||
[providerId]: { ...(prev[providerId] || {}), [key]: value },
|
||||
}))
|
||||
}
|
||||
|
||||
const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => {
|
||||
const values = providerValues[provider.id] || {}
|
||||
return getProviderFields(provider).some(field => {
|
||||
if (!field.required) return false
|
||||
return !(values[field.key] || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveProvider = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
if (!cfg.settings_put) return
|
||||
setSaving(s => ({ ...s, [provider.id]: true }))
|
||||
try {
|
||||
await apiClient.put(cfg.settings_put, buildProviderPayload(provider))
|
||||
await refreshProviderConnection(provider)
|
||||
toast.success(t('memories.saved', { provider_name: provider.name }))
|
||||
} catch {
|
||||
toast.error(t('memories.saveError', { provider_name: provider.name }))
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestProvider = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const testPath = cfg.test_post || cfg.test_get || cfg.status_get
|
||||
if (!testPath) return
|
||||
setProviderTesting(prev => ({ ...prev, [provider.id]: true }))
|
||||
try {
|
||||
const payload = buildProviderPayload(provider)
|
||||
const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath)
|
||||
const ok = !!res.data?.connected
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: ok }))
|
||||
if (ok) {
|
||||
toast.success(t('memories.connectionSuccess', { provider_name: provider.name }))
|
||||
} else {
|
||||
toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError', { provider_name: provider.name }))
|
||||
} finally {
|
||||
setProviderTesting(prev => ({ ...prev, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => {
|
||||
const fields = getProviderFields(provider)
|
||||
const cfg = getProviderConfig(provider)
|
||||
const values = providerValues[provider.id] || {}
|
||||
const connected = !!providerConnected[provider.id]
|
||||
const testing = !!providerTesting[provider.id]
|
||||
const canSave = !!cfg.settings_put
|
||||
const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get)
|
||||
|
||||
return (
|
||||
<Section key={provider.id} title={provider.name || provider.id} icon={Camera}>
|
||||
<div className="space-y-3">
|
||||
{fields.map(field => (
|
||||
<div key={`${provider.id}-${field.key}`}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||
<input
|
||||
type={field.input_type || 'text'}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
|
||||
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!canSave ? 'Save route is not configured for this provider' : isProviderSaveDisabled(provider) ? 'Please fill all required fields' : ''}
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTestProvider(provider)}
|
||||
disabled={!canTest || testing}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||
title={!canTest ? 'Test route is not configured for this provider' : ''}
|
||||
>
|
||||
{testing
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{connected && (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!memoriesEnabled) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <>{activePhotoProviders.map(provider => renderPhotoProviderSection(provider))}</>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', marginBottom: 24 }}>
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button onClick={onToggle}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: on ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import apiClient from '../../api/client'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { formatDate as fmtDate } from '../../utils/formatters'
|
||||
import {
|
||||
CheckSquare, Square, Plus, ChevronRight, Flag,
|
||||
X, Check, Calendar, User, FolderPlus, AlertCircle, ListChecks, Inbox, CheckCheck, Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { TodoItem } from '../../types'
|
||||
|
||||
const KAT_COLORS = [
|
||||
'#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316',
|
||||
'#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6',
|
||||
]
|
||||
|
||||
const PRIO_CONFIG: Record<number, { label: string; color: string }> = {
|
||||
1: { label: 'P1', color: '#ef4444' },
|
||||
2: { label: 'P2', color: '#f59e0b' },
|
||||
3: { label: 'P3', color: '#3b82f6' },
|
||||
}
|
||||
|
||||
function katColor(kat: string, allCategories: string[]) {
|
||||
const idx = allCategories.indexOf(kat)
|
||||
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
|
||||
let h = 0
|
||||
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
|
||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
|
||||
|
||||
interface Member { id: number; username: string; avatar: string | null }
|
||||
|
||||
export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
|
||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
|
||||
const canEdit = useCanDo('packing_edit')
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const formatDate = (d: string) => fmtDate(d, locale) || d
|
||||
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 767px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
const [filter, setFilter] = useState<FilterType>('all')
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [isAddingNew, setIsAddingNew] = useState(false)
|
||||
const [sortByPrio, setSortByPrio] = useState(false)
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get(`/trips/${tripId}/members`).then(r => {
|
||||
const owner = r.data?.owner
|
||||
const mems = r.data?.members || []
|
||||
const all = owner ? [owner, ...mems] : mems
|
||||
setMembers(all)
|
||||
setCurrentUserId(r.data?.current_user_id || null)
|
||||
}).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
items.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [items])
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result: TodoItem[]
|
||||
if (filter === 'all') result = items.filter(i => !i.checked)
|
||||
else if (filter === 'done') result = items.filter(i => !!i.checked)
|
||||
else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId)
|
||||
else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today)
|
||||
else result = items.filter(i => i.category === filter)
|
||||
if (sortByPrio) result = [...result].sort((a, b) => {
|
||||
const ap = a.priority || 99
|
||||
const bp = b.priority || 99
|
||||
return ap - bp
|
||||
})
|
||||
return result
|
||||
}, [items, filter, currentUserId, today, sortByPrio])
|
||||
|
||||
const selectedItem = items.find(i => i.id === selectedId) || null
|
||||
const totalCount = items.length
|
||||
const doneCount = items.filter(i => !!i.checked).length
|
||||
const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length
|
||||
const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0
|
||||
|
||||
const addCategory = () => {
|
||||
const name = newCategoryName.trim()
|
||||
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
|
||||
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
|
||||
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : 'Error'))
|
||||
}
|
||||
|
||||
// Get category count (non-done items)
|
||||
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
|
||||
|
||||
// Sidebar filter item
|
||||
const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => (
|
||||
<button onClick={() => setFilter(id as FilterType)}
|
||||
title={isMobile ? label : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
background: filter === id ? 'var(--bg-hover)' : 'transparent',
|
||||
color: filter === id ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: filter === id ? 600 : 400, transition: 'all 0.1s',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={e => { if (filter !== id) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (filter !== id) e.currentTarget.style.background = 'transparent' }}>
|
||||
{color ? (
|
||||
<span style={{ width: isMobile ? 12 : 10, height: isMobile ? 12 : 10, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
) : (
|
||||
<Icon size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
)}
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{label}</span>}
|
||||
{!isMobile && count > 0 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', background: 'var(--bg-hover)', borderRadius: 10, padding: '1px 7px', minWidth: 20, textAlign: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{isMobile && count > 0 && (
|
||||
<span style={{ position: 'absolute', top: 2, right: 2, fontSize: 8, fontWeight: 700, color: 'var(--bg-primary)', background: 'var(--text-faint)', borderRadius: '50%', width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
// Filter title
|
||||
const filterTitle = (() => {
|
||||
if (filter === 'all') return t('todo.filter.all')
|
||||
if (filter === 'done') return t('todo.filter.done')
|
||||
if (filter === 'my') return t('todo.filter.my')
|
||||
if (filter === 'overdue') return t('todo.filter.overdue')
|
||||
return filter
|
||||
})()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 180px)', minHeight: 400 }}>
|
||||
|
||||
{/* ── Left Sidebar ── */}
|
||||
<div style={{
|
||||
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
|
||||
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
||||
transition: 'width 0.2s',
|
||||
}}>
|
||||
{/* Progress Card */}
|
||||
{!isMobile && <div style={{
|
||||
margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
|
||||
background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-0.02em' }}>
|
||||
{totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: 'var(--border-faint)', borderRadius: 2, overflow: 'hidden', marginBottom: 6 }}>
|
||||
<div style={{ height: '100%', width: totalCount > 0 ? `${Math.round((doneCount / totalCount) * 100)}%` : '0%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{doneCount} / {totalCount} {t('todo.completed')}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Smart filters */}
|
||||
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('todo.sidebar.tasks')}
|
||||
</div>}
|
||||
<SidebarItem id="all" icon={Inbox} label={t('todo.filter.all')} count={items.filter(i => !i.checked).length} />
|
||||
<SidebarItem id="my" icon={User} label={t('todo.filter.my')} count={myCount} />
|
||||
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
|
||||
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
|
||||
|
||||
{/* Sort by priority */}
|
||||
<button onClick={() => setSortByPrio(v => !v)}
|
||||
title={isMobile ? t('todo.sortByPrio') : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
background: sortByPrio ? '#f59e0b12' : 'transparent',
|
||||
color: sortByPrio ? '#f59e0b' : 'var(--text-secondary)',
|
||||
fontWeight: sortByPrio ? 600 : 400, transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
|
||||
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
|
||||
</button>
|
||||
|
||||
{/* Categories */}
|
||||
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('todo.sidebar.categories')}
|
||||
</div>}
|
||||
{isMobile && <div style={{ height: 1, background: 'var(--border-faint)', margin: '8px 4px' }} />}
|
||||
{categories.map(cat => (
|
||||
<SidebarItem key={cat} id={cat} icon={null} label={cat} count={catCount(cat)} color={katColor(cat, categories)} />
|
||||
))}
|
||||
|
||||
{canEdit && (
|
||||
addingCategory && !isMobile ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 12px' }}>
|
||||
<input autoFocus value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCategoryName('') } }}
|
||||
placeholder={t('todo.newCategory')}
|
||||
style={{ flex: 1, fontSize: 12, padding: '4px 6px', border: '1px solid var(--border-primary)', borderRadius: 5, background: 'var(--bg-hover)', color: 'var(--text-primary)', fontFamily: 'inherit', minWidth: 0 }} />
|
||||
<button onClick={addCategory} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#22c55e', padding: 2 }}><Check size={13} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setAddingCategory(true)}
|
||||
title={isMobile ? t('todo.addCategory') : undefined}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start', gap: isMobile ? 0 : 6, padding: isMobile ? '8px 0' : '7px 12px', fontSize: 12, color: 'var(--text-faint)', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', width: '100%', textAlign: 'left' }}>
|
||||
<Plus size={isMobile ? 18 : 13} /> {!isMobile && t('todo.addCategory')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Middle: Task List ── */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.02em' }}>
|
||||
{filterTitle}
|
||||
</h2>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)', background: 'var(--bg-hover)', borderRadius: 6, padding: '2px 8px', fontWeight: 600 }}>
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add task */}
|
||||
{canEdit && (
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<button
|
||||
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '9px 16px', borderRadius: 8,
|
||||
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
|
||||
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
|
||||
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
|
||||
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
|
||||
<Plus size={14} />
|
||||
{t('todo.addItem')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
||||
{filtered.length === 0 ? null : (
|
||||
filtered.map(item => {
|
||||
const done = !!item.checked
|
||||
const assignedUser = members.find(m => m.id === item.assigned_user_id)
|
||||
const isOverdue = item.due_date && !done && item.due_date < today
|
||||
const isSelected = selectedId === item.id
|
||||
const catColor = item.category ? katColor(item.category, categories) : null
|
||||
|
||||
return (
|
||||
<div key={item.id}
|
||||
onClick={() => { setSelectedId(isSelected ? null : item.id); setIsAddingNew(false) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
|
||||
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
|
||||
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
|
||||
|
||||
{/* Checkbox */}
|
||||
<button onClick={e => { e.stopPropagation(); canEdit && toggleTodoItem(tripId, item.id, !done) }}
|
||||
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
|
||||
color: done ? '#22c55e' : 'var(--border-primary)' }}>
|
||||
{done ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 14, color: done ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
textDecoration: done ? 'line-through' : 'none', lineHeight: 1.4,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{item.name}
|
||||
</div>
|
||||
{/* Description preview */}
|
||||
{item.description && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
{/* Inline badges */}
|
||||
{(item.priority || item.due_date || catColor || assignedUser) && (
|
||||
<div style={{ display: 'flex', gap: 5, marginTop: 5, flexWrap: 'wrap' }}>
|
||||
{item.priority > 0 && PRIO_CONFIG[item.priority] && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 600,
|
||||
color: PRIO_CONFIG[item.priority].color,
|
||||
background: `${PRIO_CONFIG[item.priority].color}10`,
|
||||
border: `1px solid ${PRIO_CONFIG[item.priority].color}25`,
|
||||
}}>
|
||||
<Flag size={9} />{PRIO_CONFIG[item.priority].label}
|
||||
</span>
|
||||
)}
|
||||
{item.due_date && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: isOverdue ? '#ef4444' : 'var(--text-secondary)',
|
||||
background: isOverdue ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)',
|
||||
border: `1px solid ${isOverdue ? 'rgba(239,68,68,0.15)' : 'var(--border-faint)'}`,
|
||||
}}>
|
||||
<Calendar size={9} />{formatDate(item.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{catColor && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: catColor, flexShrink: 0 }} />
|
||||
{item.category}
|
||||
</span>
|
||||
)}
|
||||
{assignedUser && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{assignedUser.avatar ? (
|
||||
<img src={`/uploads/avatars/${assignedUser.avatar}`} style={{ width: 13, height: 13, borderRadius: '50%', objectFit: 'cover' }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 13, height: 13, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, color: 'var(--text-faint)', fontWeight: 700 }}>
|
||||
{assignedUser.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{assignedUser.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight size={16} color="var(--text-faint)" style={{ flexShrink: 0, opacity: 0.4 }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Detail Pane ── */}
|
||||
{selectedItem && !isAddingNew && !isMobile && (
|
||||
<DetailPane
|
||||
item={selectedItem}
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
{selectedItem && !isAddingNew && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<DetailPane
|
||||
item={selectedItem}
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && !isMobile && (
|
||||
<NewTaskPane
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<NewTaskPane
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Detail Pane (right side) ──────────────────────────────────────────────
|
||||
|
||||
function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
item: TodoItem; tripId: number; categories: string[]; members: Member[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { updateTodoItem, deleteTodoItem } = useTripStore()
|
||||
const canEdit = useCanDo('packing_edit')
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [name, setName] = useState(item.name)
|
||||
const [desc, setDesc] = useState(item.description || '')
|
||||
const [dueDate, setDueDate] = useState(item.due_date || '')
|
||||
const [category, setCategory] = useState(item.category || '')
|
||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
||||
const [priority, setPriority] = useState(item.priority || 0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Sync when selected item changes
|
||||
useEffect(() => {
|
||||
setName(item.name)
|
||||
setDesc(item.description || '')
|
||||
setDueDate(item.due_date || '')
|
||||
setCategory(item.category || '')
|
||||
setAssignedUserId(item.assigned_user_id)
|
||||
setPriority(item.priority || 0)
|
||||
}, [item.id, item.name, item.description, item.due_date, item.category, item.assigned_user_id, item.priority])
|
||||
|
||||
const hasChanges = name !== item.name || desc !== (item.description || '') ||
|
||||
dueDate !== (item.due_date || '') || category !== (item.category || '') ||
|
||||
assignedUserId !== item.assigned_user_id || priority !== (item.priority || 0)
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim() || !hasChanges) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateTodoItem(tripId, item.id, {
|
||||
name: name.trim(), description: desc || null,
|
||||
due_date: dueDate || null, category: category || null,
|
||||
assigned_user_id: assignedUserId, priority,
|
||||
} as any)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteTodoItem(tripId, item.id)
|
||||
onClose()
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 320, flexShrink: 0, borderLeft: '1px solid var(--border-faint)',
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{t('todo.detail.title')}</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4 }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<input value={name} onChange={e => setName(e.target.value)} disabled={!canEdit}
|
||||
style={{ ...inputStyle, fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent' }}
|
||||
placeholder={t('todo.namePlaceholder')} />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.description')}</label>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} disabled={!canEdit} rows={4}
|
||||
placeholder={t('todo.descriptionPlaceholder')}
|
||||
style={{ ...inputStyle, resize: 'vertical', minHeight: 80 }} />
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.priority')}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[0, 1, 2, 3].map(p => {
|
||||
const cfg = PRIO_CONFIG[p]
|
||||
const isActive = priority === p
|
||||
return (
|
||||
<button key={p} onClick={() => canEdit && setPriority(p)}
|
||||
style={{
|
||||
flex: 1, padding: '6px 0', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: canEdit ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
border: `1px solid ${isActive && cfg ? cfg.color + '40' : 'var(--border-primary)'}`,
|
||||
background: isActive && cfg ? cfg.color + '12' : 'transparent',
|
||||
color: isActive && cfg ? cfg.color : isActive ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.1s',
|
||||
}}>
|
||||
{cfg ? <><Flag size={10} />{cfg.label}</> : t('todo.detail.noPriority')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(v)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c,
|
||||
label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.dueDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={dueDate}
|
||||
onChange={v => setDueDate(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assigned to */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.assignedTo')}</label>
|
||||
<CustomSelect
|
||||
value={String(assignedUserId ?? '')}
|
||||
onChange={v => setAssignedUserId(v ? Number(v) : null)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> },
|
||||
...members.map(m => ({
|
||||
value: String(m.id),
|
||||
label: m.username,
|
||||
icon: m.avatar ? (
|
||||
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: 'var(--text-faint)', fontWeight: 600 }}>
|
||||
{m.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.unassigned')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{canEdit && (
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
|
||||
<button onClick={handleDelete}
|
||||
style={{
|
||||
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: '1px solid var(--border-primary)', background: 'transparent', color: 'var(--text-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
}}>
|
||||
<Trash2 size={13} />
|
||||
{t('todo.detail.delete')}
|
||||
</button>
|
||||
<button onClick={save} disabled={!hasChanges || saving}
|
||||
style={{
|
||||
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: hasChanges ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
border: 'none', background: hasChanges ? 'var(--text-primary)' : 'var(--border-faint)',
|
||||
color: hasChanges ? 'var(--bg-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{saving ? '...' : t('todo.detail.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── New Task Pane (right side, for creating) ──────────────────────────────
|
||||
|
||||
function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated, onClose }: {
|
||||
tripId: number; categories: string[]; members: Member[]; defaultCategory: string | null;
|
||||
onCreated: (id: number) => void; onClose: () => void;
|
||||
}) {
|
||||
const { addTodoItem } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [desc, setDesc] = useState('')
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [category, setCategory] = useState(defaultCategory || '')
|
||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const item = await addTodoItem(tripId, {
|
||||
name: name.trim(), description: desc || null, priority,
|
||||
due_date: dueDate || null, category: category || null,
|
||||
assigned_user_id: assignedUserId,
|
||||
} as any)
|
||||
if (item?.id) onCreated(item.id)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 320, flexShrink: 0, borderLeft: '1px solid var(--border-faint)',
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{t('todo.newItem')}</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4 }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<input autoFocus value={name} onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && name.trim()) create() }}
|
||||
style={{ width: '100%', fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent', color: 'var(--text-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||
placeholder={t('todo.namePlaceholder')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.description')}</label>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={4}
|
||||
placeholder={t('todo.descriptionPlaceholder')}
|
||||
style={{ width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', resize: 'vertical', minHeight: 80 }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(v)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c, label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.priority')}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[0, 1, 2, 3].map(p => {
|
||||
const cfg = PRIO_CONFIG[p]
|
||||
const isActive = priority === p
|
||||
return (
|
||||
<button key={p} onClick={() => setPriority(p)}
|
||||
style={{
|
||||
flex: 1, padding: '6px 0', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
border: `1px solid ${isActive && cfg ? cfg.color + '40' : 'var(--border-primary)'}`,
|
||||
background: isActive && cfg ? cfg.color + '12' : 'transparent',
|
||||
color: isActive && cfg ? cfg.color : isActive ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.1s',
|
||||
}}>
|
||||
{cfg ? <><Flag size={10} />{cfg.label}</> : t('todo.detail.noPriority')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.dueDate')}</label>
|
||||
<CustomDatePicker value={dueDate} onChange={v => setDueDate(v)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.assignedTo')}</label>
|
||||
<CustomSelect
|
||||
value={String(assignedUserId ?? '')}
|
||||
onChange={v => setAssignedUserId(v ? Number(v) : null)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> },
|
||||
...members.map(m => ({
|
||||
value: String(m.id), label: m.username,
|
||||
icon: m.avatar ? (
|
||||
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: 'var(--text-faint)', fontWeight: 600 }}>
|
||||
{m.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.unassigned')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-faint)' }}>
|
||||
<button onClick={create} disabled={!name.trim() || saving}
|
||||
style={{
|
||||
width: '100%', padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: name.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
border: 'none', background: name.trim() ? 'var(--text-primary)' : 'var(--border-faint)',
|
||||
color: name.trim() ? 'var(--bg-primary)' : 'var(--text-faint)', transition: 'all 0.15s',
|
||||
}}>
|
||||
{saving ? '...' : t('todo.detail.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
|
||||
import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
|
||||
import { tripsApi, authApi } from '../../api/client'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -23,13 +24,21 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
|
||||
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
|
||||
const can = useCanDo()
|
||||
const canUploadCover = !isEditing || can('trip_cover_upload', trip)
|
||||
const canEditTrip = !isEditing || can('trip_edit', trip)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reminder_days: 0 as number,
|
||||
day_count: 7,
|
||||
})
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [coverPreview, setCoverPreview] = useState(null)
|
||||
@@ -41,25 +50,41 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
|
||||
useEffect(() => {
|
||||
if (trip) {
|
||||
const rd = trip.reminder_days ?? 3
|
||||
setFormData({
|
||||
title: trip.title || '',
|
||||
description: trip.description || '',
|
||||
start_date: trip.start_date || '',
|
||||
end_date: trip.end_date || '',
|
||||
reminder_days: rd,
|
||||
day_count: trip.day_count || 7,
|
||||
})
|
||||
setCustomReminder(![0, 1, 3, 9].includes(rd))
|
||||
setCoverPreview(trip.cover_image || null)
|
||||
} else {
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 })
|
||||
setCustomReminder(false)
|
||||
setCoverPreview(null)
|
||||
}
|
||||
setPendingCoverFile(null)
|
||||
setSelectedMembers([])
|
||||
setError('')
|
||||
if (isOpen) {
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (!trip) {
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
}
|
||||
}, [trip, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trip && isOpen) {
|
||||
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
|
||||
}
|
||||
}, [tripRemindersEnabled])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -74,6 +99,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
description: formData.description.trim() || null,
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
reminder_days: formData.reminder_days,
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
||||
})
|
||||
// Add selected members for newly created trips
|
||||
if (selectedMembers.length > 0 && result?.trip?.id) {
|
||||
@@ -154,6 +181,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
|
||||
// Paste support for cover image
|
||||
const handlePaste = (e) => {
|
||||
if (!canUploadCover) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -172,10 +200,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (!prev.end_date || prev.end_date < value) {
|
||||
next.end_date = value
|
||||
} else if (prev.start_date) {
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
|
||||
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
||||
const newEnd = new Date(value + 'T00:00:00')
|
||||
const newEnd = new Date(value + 'T00:00:00Z')
|
||||
newEnd.setDate(newEnd.getDate() + duration)
|
||||
next.end_date = newEnd.toISOString().split('T')[0]
|
||||
}
|
||||
@@ -211,8 +239,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Cover image — available for both create and edit */}
|
||||
<div>
|
||||
{/* Cover image — gated by trip_cover_upload permission */}
|
||||
{canUploadCover && <div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
||||
{coverPreview ? (
|
||||
@@ -240,20 +268,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
||||
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
|
||||
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
||||
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
|
||||
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
|
||||
@@ -272,6 +300,71 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!formData.start_date && !formData.end_date && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('dashboard.dayCount')}
|
||||
</label>
|
||||
<input type="number" min={1} max={365} value={formData.day_count}
|
||||
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
||||
className={inputCls} />
|
||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reminder — only visible to owner (or when creating) */}
|
||||
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
|
||||
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<Bell className="inline w-4 h-4 mr-1" />{t('trips.reminder')}
|
||||
</label>
|
||||
{!tripRemindersEnabled ? (
|
||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||
{t('trips.reminderDisabledHint')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: 0, label: t('trips.reminderNone') },
|
||||
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
|
||||
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
|
||||
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
|
||||
].map(opt => (
|
||||
<button key={opt.value} type="button"
|
||||
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||
!customReminder && formData.reminder_days === opt.value
|
||||
? 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button"
|
||||
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||
customReminder
|
||||
? 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
|
||||
}`}>
|
||||
{t('trips.reminderCustom')}
|
||||
</button>
|
||||
</div>
|
||||
{customReminder && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="number" min={1} max={30}
|
||||
value={formData.reminder_days}
|
||||
onChange={e => update('reminder_days', Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||
className="w-20 px-3 py-1.5 border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
<span className="text-xs text-slate-500">{t('trips.reminderDaysBefore')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members — only for new trips */}
|
||||
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||
<div>
|
||||
@@ -312,11 +405,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formData.start_date && !formData.end_date && (
|
||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||
{t('dashboard.noDateHint')}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
@@ -32,7 +34,7 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
|
||||
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)
|
||||
@@ -172,6 +174,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
const toast = useToast()
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canManageMembers = can('member_manage', trip)
|
||||
const canManageShare = can('share_manage', trip)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && tripId) {
|
||||
@@ -247,7 +253,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||
<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 */}
|
||||
@@ -260,7 +266,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</div>
|
||||
|
||||
{/* Add member dropdown */}
|
||||
<div>
|
||||
{canManageMembers && <div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
{t('members.inviteUser')}
|
||||
</label>
|
||||
@@ -293,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
||||
</button>
|
||||
</div>
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && (
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Members list */}
|
||||
<div>
|
||||
@@ -317,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{allMembers.map(member => {
|
||||
const isSelf = member.id === user?.id
|
||||
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
|
||||
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||
return (
|
||||
<div key={member.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
@@ -358,9 +364,9 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</div>
|
||||
|
||||
{/* Right column: Share Link */}
|
||||
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||
<ShareLinkSection tripId={tripId} t={t} />
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function VacayMonthCard({
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
title={holiday ? (holiday.label ? `${holiday.label}: ${holiday.localName}` : holiday.localName) : undefined}
|
||||
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
||||
style={{
|
||||
height: 28,
|
||||
|
||||
@@ -104,18 +104,18 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return weekendDays.includes(d.getDay())
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return weekendDays.includes(d.getUTCDay())
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getUTCDay()]
|
||||
}
|
||||
|
||||
export function getWeekdayFull(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getUTCDay()]
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
@@ -123,8 +123,8 @@ export function daysInMonth(year: number, month: number): number {
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string, locale?: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER }
|
||||
|
||||
@@ -11,17 +11,19 @@ interface CustomDatePickerProps {
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
compact?: boolean
|
||||
borderless?: boolean
|
||||
}
|
||||
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {}, compact = false, borderless = false }: CustomDatePickerProps) {
|
||||
const { locale, t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const parsed = value ? new Date(value + 'T00:00:00') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
||||
const parsed = value ? new Date(value + 'T00:00:00Z') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -34,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
|
||||
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
|
||||
}, [open])
|
||||
|
||||
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
|
||||
@@ -45,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
|
||||
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
|
||||
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null
|
||||
|
||||
const selectDay = (day: number) => {
|
||||
const y = String(viewYear)
|
||||
@@ -55,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
|
||||
const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null
|
||||
const today = new Date()
|
||||
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||
|
||||
@@ -97,16 +99,16 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
|
||||
) : (
|
||||
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: compact ? 4 : 8,
|
||||
padding: compact ? '4px 6px' : '8px 14px', borderRadius: compact ? 4 : 10,
|
||||
border: borderless ? 'none' : '1px solid var(--border-primary)',
|
||||
background: borderless ? 'transparent' : 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
{!compact && <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />}
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CustomSelectProps {
|
||||
searchable?: boolean
|
||||
style?: React.CSSProperties
|
||||
size?: 'sm' | 'md'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
@@ -29,6 +30,7 @@ export default function CustomSelect({
|
||||
searchable = false,
|
||||
style = {},
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
}: CustomSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -83,17 +85,19 @@ export default function CustomSelect({
|
||||
{/* Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(o => !o); setSearch('') }}
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
||||
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
||||
cursor: disabled ? 'default' : 'pointer', outline: 'none', textAlign: 'left',
|
||||
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value
|
||||
onChange(raw)
|
||||
if (is12h) return // let handleBlur parse 12h formats
|
||||
const clean = raw.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
||||
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
||||
@@ -80,7 +81,23 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!value) return
|
||||
const clean = value.replace(/[^0-9:]/g, '')
|
||||
const raw = value.trim()
|
||||
|
||||
// Parse 12h input like "5:30 PM", "5:30pm", "530pm"
|
||||
if (is12h) {
|
||||
const match12 = raw.match(/^(\d{1,2}):?(\d{2})?\s*(am|pm)$/i)
|
||||
if (match12) {
|
||||
let h = parseInt(match12[1])
|
||||
const m = match12[2] ? parseInt(match12[2]) : 0
|
||||
const isPm = match12[3].toLowerCase() === 'pm'
|
||||
if (h === 12) h = isPm ? 12 : 0
|
||||
else if (isPm) h += 12
|
||||
onChange(String(Math.min(23, h)).padStart(2, '0') + ':' + String(Math.min(59, m)).padStart(2, '0'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const clean = raw.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{1,2}:\d{2}$/.test(clean)) {
|
||||
const [hh, mm] = clean.split(':')
|
||||
const h = Math.min(23, Math.max(0, parseInt(hh)))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface Category {
|
||||
@@ -14,57 +14,52 @@ interface PlaceAvatarProps {
|
||||
category?: Category | null
|
||||
}
|
||||
|
||||
const photoCache = new Map<string, string | null>()
|
||||
const photoInFlight = new Set<string>()
|
||||
// Event-based notification instead of polling intervals
|
||||
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
|
||||
|
||||
function notifyListeners(key: string, url: string | null) {
|
||||
const listeners = photoListeners.get(key)
|
||||
if (listeners) {
|
||||
listeners.forEach(fn => fn(url))
|
||||
photoListeners.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Observe visibility — fetch photo only when avatar enters viewport
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setVisible(true); return }
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
// Check if already cached — show immediately without waiting for intersection
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||
if (cacheKey && getCached(cacheKey)) { setVisible(true); return }
|
||||
|
||||
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [place.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
||||
|
||||
const cacheKey = photoId || `${place.lat},${place.lng}`
|
||||
if (photoCache.has(cacheKey)) {
|
||||
const cached = photoCache.get(cacheKey)
|
||||
if (cached) setPhotoSrc(cached)
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached) {
|
||||
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
|
||||
if (!cached.thumbDataUrl && cached.photoUrl) {
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (photoInFlight.has(cacheKey)) {
|
||||
// Subscribe to notification instead of polling
|
||||
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
|
||||
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
|
||||
photoListeners.get(cacheKey)!.add(handler)
|
||||
return () => { photoListeners.get(cacheKey)?.delete(handler) }
|
||||
if (isLoading(cacheKey)) {
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}
|
||||
|
||||
photoInFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
.then((data: { photoUrl?: string }) => {
|
||||
const url = data.photoUrl || null
|
||||
photoCache.set(cacheKey, url)
|
||||
if (url) setPhotoSrc(url)
|
||||
notifyListeners(cacheKey, url)
|
||||
photoInFlight.delete(cacheKey)
|
||||
})
|
||||
.catch(() => {
|
||||
photoCache.set(cacheKey, null)
|
||||
notifyListeners(cacheKey, null)
|
||||
photoInFlight.delete(cacheKey)
|
||||
})
|
||||
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
|
||||
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||
)
|
||||
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
|
||||
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
|
||||
|
||||
const bgColor = category?.color || '#6366f1'
|
||||
const IconComp = getCategoryIcon(category?.icon)
|
||||
@@ -81,11 +76,11 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
|
||||
if (photoSrc) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div ref={ref} style={containerStyle}>
|
||||
<img
|
||||
src={photoSrc}
|
||||
alt={place.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
/>
|
||||
@@ -94,7 +89,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div ref={ref} style={containerStyle}>
|
||||
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
|
||||
|
||||
export function useInAppNotificationListener(): void {
|
||||
const handleNew = useInAppNotificationStore(s => s.handleNewNotification)
|
||||
const handleUpdated = useInAppNotificationStore(s => s.handleUpdatedNotification)
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Record<string, unknown>) => {
|
||||
if (event.type === 'notification:new') {
|
||||
handleNew(event.notification as any)
|
||||
} else if (event.type === 'notification:updated') {
|
||||
handleUpdated(event.notification as any)
|
||||
}
|
||||
}
|
||||
addListener(listener)
|
||||
return () => removeListener(listener)
|
||||
}, [handleNew, handleUpdated])
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRef, useReducer } from 'react'
|
||||
|
||||
export interface UndoEntry {
|
||||
label: string
|
||||
undo: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function usePlannerHistory(maxEntries = 30) {
|
||||
const historyRef = useRef<UndoEntry[]>([])
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)
|
||||
|
||||
const pushUndo = (label: string, undoFn: () => Promise<void> | void) => {
|
||||
historyRef.current = [{ label, undo: undoFn }, ...historyRef.current].slice(0, maxEntries)
|
||||
forceUpdate()
|
||||
}
|
||||
|
||||
const undo = async () => {
|
||||
if (historyRef.current.length === 0) return
|
||||
const [first, ...rest] = historyRef.current
|
||||
historyRef.current = rest
|
||||
forceUpdate()
|
||||
try { await first.undo() } catch (e) { console.error('Undo failed:', e) }
|
||||
}
|
||||
|
||||
const canUndo = historyRef.current.length > 0
|
||||
const lastActionLabel = historyRef.current[0]?.label ?? null
|
||||
|
||||
return { pushUndo, undo, canUndo, lastActionLabel }
|
||||
}
|
||||
@@ -15,11 +15,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
|
||||
const tripStoreRef = useRef(tripStore)
|
||||
tripStoreRef.current = tripStore
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const currentAssignments = tripStoreRef.current.assignments || {}
|
||||
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
||||
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
||||
@@ -33,12 +37,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||
else if (!(err instanceof Error)) setRouteSegments([])
|
||||
}
|
||||
}, [tripStore, routeCalcEnabled])
|
||||
}, [routeCalcEnabled])
|
||||
|
||||
// Only recalculate when assignments for the SELECTED day change
|
||||
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
|
||||
useEffect(() => {
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
}, [selectedDayId, tripStore.assignments])
|
||||
}, [selectedDayId, selectedDayAssignments])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import hu from './translations/hu'
|
||||
import it from './translations/it'
|
||||
import ru from './translations/ru'
|
||||
import zh from './translations/zh'
|
||||
import zhTw from './translations/zhTw'
|
||||
import nl from './translations/nl'
|
||||
import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
import cs from './translations/cs'
|
||||
import pl from './translations/pl'
|
||||
|
||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||
|
||||
@@ -24,14 +26,16 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'br', label: 'Português (Brasil)' },
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
{ value: 'zh-TW', label: '繁體中文' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
] as const
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
|
||||
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' }
|
||||
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl }
|
||||
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', 'zh-TW': 'zh-TW', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ', pl: 'pl-PL' }
|
||||
const RTL_LANGUAGES = new Set(['ar'])
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
@@ -40,7 +44,7 @@ export function getLocaleForLanguage(language: string): string {
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
if (language === 'br') return 'pt-BR'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl'].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
export function isRtlLanguage(language: string): boolean {
|
||||
|
||||
@@ -10,6 +10,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.edit': 'تعديل',
|
||||
'common.add': 'إضافة',
|
||||
'common.loading': 'جارٍ التحميل...',
|
||||
'common.import': 'استيراد',
|
||||
'common.error': 'خطأ',
|
||||
'common.back': 'رجوع',
|
||||
'common.all': 'الكل',
|
||||
@@ -29,6 +30,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.email': 'البريد الإلكتروني',
|
||||
'common.password': 'كلمة المرور',
|
||||
'common.saving': 'جارٍ الحفظ...',
|
||||
'common.saved': 'تم الحفظ',
|
||||
'trips.reminder': 'تذكير',
|
||||
'trips.reminderNone': 'بدون',
|
||||
'trips.reminderDay': 'يوم',
|
||||
'trips.reminderDays': 'أيام',
|
||||
'trips.reminderCustom': 'مخصص',
|
||||
'trips.reminderDaysBefore': 'أيام قبل المغادرة',
|
||||
'trips.reminderDisabledHint': 'تذكيرات الرحلة معطلة. قم بتفعيلها من الإدارة > الإعدادات > الإشعارات.',
|
||||
'common.update': 'تحديث',
|
||||
'common.change': 'تغيير',
|
||||
'common.uploading': 'جارٍ الرفع...',
|
||||
@@ -76,7 +85,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.sharedBy': 'شاركها {name}',
|
||||
'dashboard.days': 'الأيام',
|
||||
'dashboard.places': 'الأماكن',
|
||||
'dashboard.members': 'ال חברים',
|
||||
'dashboard.archive': 'أرشفة',
|
||||
'dashboard.copyTrip': 'نسخ',
|
||||
'dashboard.copySuffix': 'نسخة',
|
||||
'dashboard.restore': 'استعادة',
|
||||
'dashboard.archived': 'مؤرشفة',
|
||||
'dashboard.status.ongoing': 'جارية',
|
||||
@@ -95,6 +107,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'فشل الأرشفة',
|
||||
'dashboard.toast.restored': 'تمت استعادة الرحلة',
|
||||
'dashboard.toast.restoreError': 'فشل الاستعادة',
|
||||
'dashboard.toast.copied': 'تم نسخ الرحلة!',
|
||||
'dashboard.toast.copyError': 'فشل نسخ الرحلة',
|
||||
'dashboard.confirm.delete': 'حذف الرحلة "{title}"؟ سيتم حذف جميع الأماكن والخطط نهائيًا.',
|
||||
'dashboard.editTrip': 'تعديل الرحلة',
|
||||
'dashboard.createTrip': 'إنشاء رحلة جديدة',
|
||||
@@ -104,6 +118,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'عمّ تتحدث هذه الرحلة؟',
|
||||
'dashboard.startDate': 'تاريخ البداية',
|
||||
'dashboard.endDate': 'تاريخ النهاية',
|
||||
'dashboard.dayCount': 'عدد الأيام',
|
||||
'dashboard.dayCountHint': 'عدد الأيام المراد التخطيط لها عندما لا يتم تحديد تواريخ السفر.',
|
||||
'dashboard.noDateHint': 'لا يوجد تاريخ محدد. سيتم إنشاء 7 أيام افتراضية ويمكنك تغيير ذلك لاحقًا.',
|
||||
'dashboard.coverImage': 'صورة الغلاف',
|
||||
'dashboard.addCoverImage': 'إضافة صورة غلاف',
|
||||
@@ -118,6 +134,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Settings
|
||||
'settings.title': 'الإعدادات',
|
||||
'settings.subtitle': 'ضبط إعداداتك الشخصية',
|
||||
'settings.tabs.display': 'العرض',
|
||||
'settings.tabs.map': 'الخريطة',
|
||||
'settings.tabs.notifications': 'الإشعارات',
|
||||
'settings.tabs.integrations': 'التكاملات',
|
||||
'settings.tabs.account': 'الحساب',
|
||||
'settings.tabs.about': 'حول',
|
||||
'settings.map': 'الخريطة',
|
||||
'settings.mapTemplate': 'قالب الخريطة',
|
||||
'settings.mapTemplatePlaceholder.select': 'اختر قالبًا...',
|
||||
@@ -154,9 +176,26 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
|
||||
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
|
||||
'settings.notifyWebhook': 'إشعارات Webhook',
|
||||
'settings.notificationsDisabled': 'الإشعارات غير مكوّنة. اطلب من المسؤول تفعيل إشعارات البريد الإلكتروني أو Webhook.',
|
||||
'settings.notificationsActive': 'القناة النشطة',
|
||||
'settings.notificationsManagedByAdmin': 'يتم تكوين أحداث الإشعارات بواسطة المسؤول.',
|
||||
'admin.notifications.title': 'الإشعارات',
|
||||
'admin.notifications.hint': 'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.',
|
||||
'admin.notifications.none': 'معطّل',
|
||||
'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'أحداث الإشعارات',
|
||||
'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
|
||||
'admin.notifications.configureFirst': 'قم بتكوين إعدادات SMTP أو Webhook أدناه أولاً، ثم قم بتفعيل الأحداث.',
|
||||
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
|
||||
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
|
||||
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
|
||||
'admin.notifications.testWebhookSuccess': 'تم إرسال webhook التجريبي بنجاح',
|
||||
'admin.notifications.testWebhookFailed': 'فشل إرسال webhook التجريبي',
|
||||
'admin.smtp.title': 'البريد والإشعارات',
|
||||
'admin.smtp.hint': 'تكوين SMTP لإشعارات البريد الإلكتروني. اختياري: عنوان Webhook لـ Discord أو Slack وغيرها.',
|
||||
'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
|
||||
'admin.smtp.testButton': 'إرسال بريد تجريبي',
|
||||
'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
|
||||
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
|
||||
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
|
||||
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
|
||||
@@ -190,13 +229,48 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'share.permCollab': 'الدردشة',
|
||||
'settings.on': 'تشغيل',
|
||||
'settings.off': 'إيقاف',
|
||||
'settings.mcp.title': 'إعداد MCP',
|
||||
'settings.mcp.endpoint': 'نقطة نهاية MCP',
|
||||
'settings.mcp.clientConfig': 'إعداد العميل',
|
||||
'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
|
||||
'settings.mcp.copy': 'نسخ',
|
||||
'settings.mcp.copied': 'تم النسخ!',
|
||||
'settings.mcp.apiTokens': 'رموز API',
|
||||
'settings.mcp.createToken': 'إنشاء رمز جديد',
|
||||
'settings.mcp.noTokens': 'لا توجد رموز بعد. أنشئ رمزاً للاتصال بعملاء MCP.',
|
||||
'settings.mcp.tokenCreatedAt': 'أُنشئ',
|
||||
'settings.mcp.tokenUsedAt': 'استُخدم',
|
||||
'settings.mcp.deleteTokenTitle': 'حذف الرمز',
|
||||
'settings.mcp.deleteTokenMessage': 'سيتوقف هذا الرمز عن العمل فوراً. أي عميل MCP يستخدمه سيفقد الوصول.',
|
||||
'settings.mcp.modal.createTitle': 'إنشاء رمز API',
|
||||
'settings.mcp.modal.tokenName': 'اسم الرمز',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'مثال: Claude Desktop، حاسوب العمل',
|
||||
'settings.mcp.modal.creating': 'جارٍ الإنشاء…',
|
||||
'settings.mcp.modal.create': 'إنشاء الرمز',
|
||||
'settings.mcp.modal.createdTitle': 'تم إنشاء الرمز',
|
||||
'settings.mcp.modal.createdWarning': 'سيُعرض هذا الرمز مرة واحدة فقط. انسخه واحفظه الآن — لا يمكن استرداده.',
|
||||
'settings.mcp.modal.done': 'تم',
|
||||
'settings.mcp.toast.created': 'تم إنشاء الرمز',
|
||||
'settings.mcp.toast.createError': 'فشل إنشاء الرمز',
|
||||
'settings.mcp.toast.deleted': 'تم حذف الرمز',
|
||||
'settings.mcp.toast.deleteError': 'فشل حذف الرمز',
|
||||
'settings.account': 'الحساب',
|
||||
'settings.about': 'حول',
|
||||
'settings.about.reportBug': 'الإبلاغ عن خطأ',
|
||||
'settings.about.reportBugHint': 'وجدت مشكلة؟ أخبرنا',
|
||||
'settings.about.featureRequest': 'اقتراح ميزة',
|
||||
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
|
||||
'settings.about.wikiHint': 'التوثيق والأدلة',
|
||||
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
|
||||
'settings.about.madeWith': 'صُنع بـ',
|
||||
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
|
||||
'settings.username': 'اسم المستخدم',
|
||||
'settings.email': 'البريد الإلكتروني',
|
||||
'settings.role': 'الدور',
|
||||
'settings.roleAdmin': 'مسؤول',
|
||||
'settings.oidcLinked': 'مرتبط مع',
|
||||
'settings.changePassword': 'تغيير كلمة المرور',
|
||||
'settings.mustChangePassword': 'يجب عليك تغيير كلمة المرور قبل المتابعة. يرجى تعيين كلمة مرور جديدة أدناه.',
|
||||
'settings.currentPassword': 'كلمة المرور الحالية',
|
||||
'settings.currentPasswordRequired': 'كلمة المرور الحالية مطلوبة',
|
||||
'settings.newPassword': 'كلمة المرور الجديدة',
|
||||
@@ -205,7 +279,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة',
|
||||
'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
|
||||
'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
||||
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم',
|
||||
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص',
|
||||
'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح',
|
||||
'settings.deleteAccount': 'حذف الحساب',
|
||||
'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟',
|
||||
@@ -226,6 +300,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarError': 'فشل الرفع',
|
||||
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
|
||||
'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).',
|
||||
'settings.mfa.requiredByPolicy': 'المسؤول يتطلب المصادقة الثنائية. اضبط تطبيق المصادقة أدناه قبل المتابعة.',
|
||||
'settings.mfa.backupTitle': 'رموز النسخ الاحتياطي',
|
||||
'settings.mfa.backupDescription': 'استخدم هذه الرموز لمرة واحدة إذا فقدت الوصول إلى تطبيق المصادقة.',
|
||||
'settings.mfa.backupWarning': 'احفظ هذه الرموز الآن. كل رمز يمكن استخدامه مرة واحدة فقط.',
|
||||
'settings.mfa.backupCopy': 'نسخ الرموز',
|
||||
'settings.mfa.backupDownload': 'تنزيل TXT',
|
||||
'settings.mfa.backupPrint': 'طباعة / PDF',
|
||||
'settings.mfa.backupCopied': 'تم نسخ رموز النسخ الاحتياطي',
|
||||
'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.',
|
||||
'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.',
|
||||
'settings.mfa.setup': 'إعداد المصادقة',
|
||||
@@ -268,6 +350,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.signIn': 'دخول',
|
||||
'login.createAdmin': 'إنشاء حساب مسؤول',
|
||||
'login.createAdminHint': 'أعد إعداد أول حساب مسؤول لـ TREK.',
|
||||
'login.setNewPassword': 'تعيين كلمة مرور جديدة',
|
||||
'login.setNewPasswordHint': 'يجب عليك تغيير كلمة المرور قبل المتابعة.',
|
||||
'login.createAccount': 'إنشاء حساب',
|
||||
'login.createAccountHint': 'سجّل حسابًا جديدًا.',
|
||||
'login.creating': 'جارٍ الإنشاء…',
|
||||
@@ -294,7 +378,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
||||
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 6 أحرف على الأقل',
|
||||
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
|
||||
'register.failed': 'فشل التسجيل',
|
||||
'register.getStarted': 'ابدأ الآن',
|
||||
'register.subtitle': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.',
|
||||
@@ -320,11 +404,25 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'المستخدمون',
|
||||
'admin.tabs.categories': 'الفئات',
|
||||
'admin.tabs.backup': 'النسخ الاحتياطي',
|
||||
'admin.tabs.audit': 'سجل التدقيق',
|
||||
'admin.tabs.audit': 'تدقيق',
|
||||
'admin.tabs.settings': 'الإعدادات',
|
||||
'admin.tabs.config': 'الإعدادات',
|
||||
'admin.tabs.config': 'التخصيص',
|
||||
'admin.tabs.templates': 'قوالب التعبئة',
|
||||
'admin.tabs.addons': 'الإضافات',
|
||||
'admin.tabs.mcpTokens': 'رموز MCP',
|
||||
'admin.mcpTokens.title': 'رموز MCP',
|
||||
'admin.mcpTokens.subtitle': 'إدارة رموز API لجميع المستخدمين',
|
||||
'admin.mcpTokens.owner': 'المالك',
|
||||
'admin.mcpTokens.tokenName': 'اسم الرمز',
|
||||
'admin.mcpTokens.created': 'تاريخ الإنشاء',
|
||||
'admin.mcpTokens.lastUsed': 'آخر استخدام',
|
||||
'admin.mcpTokens.never': 'أبداً',
|
||||
'admin.mcpTokens.empty': 'لم يتم إنشاء أي رموز MCP بعد',
|
||||
'admin.mcpTokens.deleteTitle': 'حذف الرمز',
|
||||
'admin.mcpTokens.deleteMessage': 'سيتم إلغاء هذا الرمز فوراً. سيفقد المستخدم وصوله إلى MCP عبر هذا الرمز.',
|
||||
'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز',
|
||||
'admin.mcpTokens.deleteError': 'فشل حذف الرمز',
|
||||
'admin.mcpTokens.loadError': 'فشل تحميل الرموز',
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.stats.users': 'المستخدمون',
|
||||
'admin.stats.trips': 'الرحلات',
|
||||
@@ -374,6 +472,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
|
||||
'admin.allowRegistration': 'السماح بالتسجيل',
|
||||
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
|
||||
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
|
||||
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
|
||||
'admin.apiKeys': 'مفاتيح API',
|
||||
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
|
||||
'admin.mapsKey': 'مفتاح Google Maps API',
|
||||
@@ -425,10 +525,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Addons
|
||||
'admin.addons.title': 'الإضافات',
|
||||
'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.',
|
||||
'admin.addons.catalog.memories.name': 'ذكريات',
|
||||
'admin.addons.catalog.memories.description': 'ألبومات صور مشتركة لكل رحلة',
|
||||
'admin.addons.catalog.packing.name': 'التعبئة',
|
||||
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
|
||||
'admin.addons.catalog.memories.name': 'صور (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
|
||||
'admin.addons.catalog.packing.name': 'القوائم',
|
||||
'admin.addons.catalog.packing.description': 'قوائم التعبئة والمهام لرحلاتك',
|
||||
'admin.addons.catalog.budget.name': 'الميزانية',
|
||||
'admin.addons.catalog.budget.description': 'تتبع النفقات وخطط ميزانية الرحلة',
|
||||
'admin.addons.catalog.documents.name': 'المستندات',
|
||||
@@ -445,8 +547,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.disabled': 'معطّل',
|
||||
'admin.addons.type.trip': 'رحلة',
|
||||
'admin.addons.type.global': 'عام',
|
||||
'admin.addons.type.integration': 'تكامل',
|
||||
'admin.addons.tripHint': 'متاح كعلامة تبويب داخل كل رحلة',
|
||||
'admin.addons.globalHint': 'متاح كقسم مستقل في التنقل الرئيسي',
|
||||
'admin.addons.integrationHint': 'خدمات الواجهة الخلفية وتكاملات API بدون صفحة مخصصة',
|
||||
'admin.addons.toast.updated': 'تم تحديث الإضافة',
|
||||
'admin.addons.toast.error': 'فشل تحديث الإضافة',
|
||||
'admin.addons.noAddons': 'لا توجد إضافات متاحة',
|
||||
@@ -510,7 +614,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'خطط وأدر أيام الإجازة',
|
||||
'vacay.settings': 'الإعدادات',
|
||||
'vacay.year': 'السنة',
|
||||
'vacay.addYear': 'إضافة سنة',
|
||||
'vacay.addYear': 'إضافة السنة التالية',
|
||||
'vacay.addPrevYear': 'إضافة السنة السابقة',
|
||||
'vacay.removeYear': 'إزالة السنة',
|
||||
'vacay.removeYearConfirm': 'إزالة {year}؟',
|
||||
'vacay.removeYearHint': 'سيتم حذف كل إدخالات الإجازات والعطل الخاصة بهذه السنة نهائيًا.',
|
||||
@@ -601,10 +706,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'إزالة',
|
||||
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
|
||||
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
|
||||
'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟',
|
||||
'atlas.markVisited': 'تعيين كمُزار',
|
||||
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
||||
'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة',
|
||||
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
||||
'atlas.addPoi': 'إضافة مكان',
|
||||
'atlas.searchCountry': 'ابحث عن دولة...',
|
||||
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
|
||||
'atlas.month': 'الشهر',
|
||||
'atlas.year': 'السنة',
|
||||
@@ -613,7 +721,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.statsTab': 'الإحصائيات',
|
||||
'atlas.bucketTab': 'قائمة الأمنيات',
|
||||
'atlas.addBucket': 'إضافة إلى قائمة الأمنيات',
|
||||
'atlas.bucketNamePlaceholder': 'مكان أو وجهة...',
|
||||
'atlas.bucketNotesPlaceholder': 'ملاحظات (اختياري)',
|
||||
'atlas.bucketEmpty': 'قائمة أمنياتك فارغة',
|
||||
'atlas.bucketEmptyHint': 'أضف أماكن تحلم بزيارتها',
|
||||
@@ -626,7 +733,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.nextTrip': 'الرحلة القادمة',
|
||||
'atlas.daysLeft': 'يوم متبقٍ',
|
||||
'atlas.streak': 'سلسلة',
|
||||
'atlas.year': 'سنة',
|
||||
'atlas.years': 'سنوات',
|
||||
'atlas.yearInRow': 'سنة متتالية',
|
||||
'atlas.yearsInRow': 'سنوات متتالية',
|
||||
@@ -653,9 +759,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.reservationsShort': 'حجز',
|
||||
'trip.tabs.packing': 'قائمة التجهيز',
|
||||
'trip.tabs.packingShort': 'تجهيز',
|
||||
'trip.tabs.lists': 'القوائم',
|
||||
'trip.tabs.listsShort': 'القوائم',
|
||||
'trip.tabs.budget': 'الميزانية',
|
||||
'trip.tabs.files': 'الملفات',
|
||||
'trip.loading': 'جارٍ تحميل الرحلة...',
|
||||
'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...',
|
||||
'trip.mobilePlan': 'الخطة',
|
||||
'trip.mobilePlaces': 'الأماكن',
|
||||
'trip.toast.placeUpdated': 'تم تحديث المكان',
|
||||
@@ -702,9 +811,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'إضافة مكان/نشاط',
|
||||
'places.importGpx': 'استيراد GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
||||
'places.gpxError': 'فشل استيراد GPX',
|
||||
'places.importGoogleList': 'قائمة Google',
|
||||
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
|
||||
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
|
||||
'places.googleListError': 'فشل استيراد قائمة Google Maps',
|
||||
'places.viewDetails': 'عرض التفاصيل',
|
||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
||||
'places.all': 'الكل',
|
||||
@@ -762,6 +876,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'حجز',
|
||||
'inspector.editRes': 'تعديل الحجز',
|
||||
'inspector.participants': 'المشاركون',
|
||||
'inspector.trackStats': 'بيانات المسار',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'الحجوزات',
|
||||
@@ -841,9 +956,36 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.linkAssignment': 'ربط بخطة اليوم',
|
||||
'reservations.pickAssignment': 'اختر عنصرًا من خطتك...',
|
||||
'reservations.noAssignment': 'بلا ربط',
|
||||
'reservations.price': 'السعر',
|
||||
'reservations.budgetCategory': 'فئة الميزانية',
|
||||
'reservations.budgetCategoryPlaceholder': 'مثال: المواصلات، الإقامة',
|
||||
'reservations.budgetCategoryAuto': 'تلقائي (حسب نوع الحجز)',
|
||||
'reservations.budgetHint': 'سيتم إنشاء إدخال في الميزانية تلقائيًا عند الحفظ.',
|
||||
'reservations.departureDate': 'المغادرة',
|
||||
'reservations.arrivalDate': 'الوصول',
|
||||
'reservations.departureTime': 'وقت المغادرة',
|
||||
'reservations.arrivalTime': 'وقت الوصول',
|
||||
'reservations.pickupDate': 'الاستلام',
|
||||
'reservations.returnDate': 'الإرجاع',
|
||||
'reservations.pickupTime': 'وقت الاستلام',
|
||||
'reservations.returnTime': 'وقت الإرجاع',
|
||||
'reservations.endDate': 'تاريخ الانتهاء',
|
||||
'reservations.meta.departureTimezone': 'TZ المغادرة',
|
||||
'reservations.meta.arrivalTimezone': 'TZ الوصول',
|
||||
'reservations.span.departure': 'المغادرة',
|
||||
'reservations.span.arrival': 'الوصول',
|
||||
'reservations.span.inTransit': 'في الطريق',
|
||||
'reservations.span.pickup': 'الاستلام',
|
||||
'reservations.span.return': 'الإرجاع',
|
||||
'reservations.span.active': 'نشط',
|
||||
'reservations.span.start': 'البداية',
|
||||
'reservations.span.end': 'النهاية',
|
||||
'reservations.span.ongoing': 'جارٍ',
|
||||
'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'الميزانية',
|
||||
'budget.exportCsv': 'تصدير CSV',
|
||||
'budget.emptyTitle': 'لم يتم إنشاء ميزانية بعد',
|
||||
'budget.emptyText': 'أنشئ فئات وإدخالات لتخطيط ميزانية سفرك',
|
||||
'budget.emptyPlaceholder': 'أدخل اسم الفئة...',
|
||||
@@ -858,6 +1000,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.table.perDay': 'لكل يوم',
|
||||
'budget.table.perPersonDay': 'لكل شخص / يوم',
|
||||
'budget.table.note': 'ملاحظة',
|
||||
'budget.table.date': 'التاريخ',
|
||||
'budget.newEntry': 'إدخال جديد',
|
||||
'budget.defaultEntry': 'إدخال جديد',
|
||||
'budget.defaultCategory': 'فئة جديدة',
|
||||
@@ -1251,6 +1394,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichUrl': 'عنوان خادم Immich',
|
||||
'memories.immichApiKey': 'مفتاح API',
|
||||
'memories.testConnection': 'اختبار الاتصال',
|
||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||
'memories.connected': 'متصل',
|
||||
'memories.disconnected': 'غير متصل',
|
||||
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
|
||||
@@ -1260,6 +1404,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.newest': 'الأحدث أولاً',
|
||||
'memories.allLocations': 'جميع المواقع',
|
||||
'memories.addPhotos': 'إضافة صور',
|
||||
'memories.linkAlbum': 'ربط ألبوم',
|
||||
'memories.selectAlbum': 'اختيار ألبوم Immich',
|
||||
'memories.noAlbums': 'لم يتم العثور على ألبومات',
|
||||
'memories.syncAlbum': 'مزامنة الألبوم',
|
||||
'memories.unlinkAlbum': 'إلغاء الربط',
|
||||
'memories.photos': 'صور',
|
||||
'memories.selectPhotos': 'اختيار صور من Immich',
|
||||
'memories.selectHint': 'انقر على الصور لتحديدها.',
|
||||
'memories.selected': 'محدد',
|
||||
@@ -1291,6 +1441,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.chat.today': 'اليوم',
|
||||
'collab.chat.yesterday': 'أمس',
|
||||
'collab.chat.deletedMessage': 'حذف رسالة',
|
||||
'collab.chat.reply': 'رد',
|
||||
'collab.chat.loadMore': 'تحميل الرسائل الأقدم',
|
||||
'collab.chat.justNow': 'الآن',
|
||||
'collab.chat.minutesAgo': 'منذ {n} د',
|
||||
@@ -1341,6 +1492,204 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.options': 'الخيارات',
|
||||
'collab.polls.delete': 'حذف',
|
||||
'collab.polls.closedSection': 'مغلق',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'الصلاحيات',
|
||||
'perm.title': 'إعدادات الصلاحيات',
|
||||
'perm.subtitle': 'التحكم في من يمكنه تنفيذ الإجراءات عبر التطبيق',
|
||||
'perm.saved': 'تم حفظ إعدادات الصلاحيات',
|
||||
'perm.resetDefaults': 'إعادة التعيين إلى الافتراضي',
|
||||
'perm.customized': 'مخصص',
|
||||
'perm.level.admin': 'المسؤول فقط',
|
||||
'perm.level.tripOwner': 'مالك الرحلة',
|
||||
'perm.level.tripMember': 'أعضاء الرحلة',
|
||||
'perm.level.everybody': 'الجميع',
|
||||
'perm.cat.trip': 'إدارة الرحلات',
|
||||
'perm.cat.members': 'إدارة الأعضاء',
|
||||
'perm.cat.files': 'الملفات',
|
||||
'perm.cat.content': 'المحتوى والجدول الزمني',
|
||||
'perm.cat.extras': 'الميزانية والتعبئة والتعاون',
|
||||
'perm.action.trip_create': 'إنشاء رحلات',
|
||||
'perm.action.trip_edit': 'تعديل تفاصيل الرحلة',
|
||||
'perm.action.trip_delete': 'حذف الرحلات',
|
||||
'perm.action.trip_archive': 'أرشفة / إلغاء أرشفة الرحلات',
|
||||
'perm.action.trip_cover_upload': 'رفع صورة الغلاف',
|
||||
'perm.action.member_manage': 'إضافة / إزالة الأعضاء',
|
||||
'perm.action.file_upload': 'رفع الملفات',
|
||||
'perm.action.file_edit': 'تعديل بيانات الملف',
|
||||
'perm.action.file_delete': 'حذف الملفات',
|
||||
'perm.action.place_edit': 'إضافة / تعديل / حذف الأماكن',
|
||||
'perm.action.day_edit': 'تعديل الأيام والملاحظات والتعيينات',
|
||||
'perm.action.reservation_edit': 'إدارة الحجوزات',
|
||||
'perm.action.budget_edit': 'إدارة الميزانية',
|
||||
'perm.action.packing_edit': 'إدارة قوائم التعبئة',
|
||||
'perm.action.collab_edit': 'التعاون (ملاحظات، استطلاعات، دردشة)',
|
||||
'perm.action.share_manage': 'إدارة روابط المشاركة',
|
||||
'perm.actionHint.trip_create': 'من يمكنه إنشاء رحلات جديدة',
|
||||
'perm.actionHint.trip_edit': 'من يمكنه تغيير اسم الرحلة والتواريخ والوصف والعملة',
|
||||
'perm.actionHint.trip_delete': 'من يمكنه حذف رحلة نهائياً',
|
||||
'perm.actionHint.trip_archive': 'من يمكنه أرشفة أو إلغاء أرشفة رحلة',
|
||||
'perm.actionHint.trip_cover_upload': 'من يمكنه رفع أو تغيير صورة الغلاف',
|
||||
'perm.actionHint.member_manage': 'من يمكنه دعوة أو إزالة أعضاء الرحلة',
|
||||
'perm.actionHint.file_upload': 'من يمكنه رفع ملفات إلى رحلة',
|
||||
'perm.actionHint.file_edit': 'من يمكنه تعديل أوصاف الملفات والروابط',
|
||||
'perm.actionHint.file_delete': 'من يمكنه نقل الملفات إلى سلة المهملات أو حذفها نهائياً',
|
||||
'perm.actionHint.place_edit': 'من يمكنه إضافة أو تعديل أو حذف الأماكن',
|
||||
'perm.actionHint.day_edit': 'من يمكنه تعديل الأيام وملاحظات الأيام وتعيينات الأماكن',
|
||||
'perm.actionHint.reservation_edit': 'من يمكنه إنشاء أو تعديل أو حذف الحجوزات',
|
||||
'perm.actionHint.budget_edit': 'من يمكنه إنشاء أو تعديل أو حذف عناصر الميزانية',
|
||||
'perm.actionHint.packing_edit': 'من يمكنه إدارة عناصر التعبئة والحقائب',
|
||||
'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل',
|
||||
'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة',
|
||||
// Undo
|
||||
'undo.button': 'تراجع',
|
||||
'undo.tooltip': 'تراجع: {action}',
|
||||
'undo.assignPlace': 'تم تعيين المكان لليوم',
|
||||
'undo.removeAssignment': 'تم إزالة المكان من اليوم',
|
||||
'undo.reorder': 'تمت إعادة ترتيب الأماكن',
|
||||
'undo.optimize': 'تم تحسين المسار',
|
||||
'undo.deletePlace': 'تم حذف المكان',
|
||||
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
|
||||
'undo.lock': 'تم تبديل قفل المكان',
|
||||
'undo.importGpx': 'استيراد GPX',
|
||||
'undo.importGoogleList': 'استيراد خرائط Google',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'الإشعارات',
|
||||
'notifications.markAllRead': 'تحديد الكل كمقروء',
|
||||
'notifications.deleteAll': 'حذف الكل',
|
||||
'notifications.showAll': 'عرض جميع الإشعارات',
|
||||
'notifications.empty': 'لا توجد إشعارات',
|
||||
'notifications.emptyDescription': 'لقد اطلعت على كل شيء!',
|
||||
'notifications.all': 'الكل',
|
||||
'notifications.unreadOnly': 'غير مقروء',
|
||||
'notifications.markRead': 'تحديد كمقروء',
|
||||
'notifications.markUnread': 'تحديد كغير مقروء',
|
||||
'notifications.delete': 'حذف',
|
||||
'notifications.system': 'النظام',
|
||||
'memories.error.loadAlbums': 'فشل تحميل الألبومات',
|
||||
'memories.error.linkAlbum': 'فشل ربط الألبوم',
|
||||
'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم',
|
||||
'memories.error.syncAlbum': 'فشل مزامنة الألبوم',
|
||||
'memories.error.loadPhotos': 'فشل تحميل الصور',
|
||||
'memories.error.addPhotos': 'فشل إضافة الصور',
|
||||
'memories.error.removePhoto': 'فشل حذف الصورة',
|
||||
'memories.error.toggleSharing': 'فشل تحديث إعدادات المشاركة',
|
||||
'undo.addPlace': 'تمت إضافة المكان',
|
||||
'undo.done': 'تم التراجع: {action}',
|
||||
'notifications.test.title': 'إشعار تجريبي من {actor}',
|
||||
'notifications.test.text': 'هذا إشعار تجريبي بسيط.',
|
||||
'notifications.test.booleanTitle': 'يطلب منك {actor} الموافقة',
|
||||
'notifications.test.booleanText': 'إشعار تجريبي يتطلب إجابة.',
|
||||
'notifications.test.accept': 'موافقة',
|
||||
'notifications.test.decline': 'رفض',
|
||||
'notifications.test.navigateTitle': 'تحقق من شيء ما',
|
||||
'notifications.test.navigateText': 'إشعار تجريبي للتنقل.',
|
||||
'notifications.test.goThere': 'اذهب إلى هناك',
|
||||
'notifications.test.adminTitle': 'إذاعة المسؤول',
|
||||
'notifications.test.adminText': 'أرسل {actor} إشعاراً تجريبياً لجميع المسؤولين.',
|
||||
'notifications.test.tripTitle': 'نشر {actor} في رحلتك',
|
||||
'notifications.test.tripText': 'إشعار تجريبي للرحلة "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'قائمة الأمتعة',
|
||||
'todo.subtab.todo': 'المهام',
|
||||
'todo.completed': 'مكتمل',
|
||||
'todo.filter.all': 'الكل',
|
||||
'todo.filter.open': 'مفتوح',
|
||||
'todo.filter.done': 'منجز',
|
||||
'todo.uncategorized': 'بدون تصنيف',
|
||||
'todo.namePlaceholder': 'اسم المهمة',
|
||||
'todo.descriptionPlaceholder': 'وصف (اختياري)',
|
||||
'todo.unassigned': 'غير مُسنَد',
|
||||
'todo.noCategory': 'بدون فئة',
|
||||
'todo.hasDescription': 'له وصف',
|
||||
'todo.addItem': 'إضافة مهمة جديدة...',
|
||||
'todo.newCategory': 'اسم الفئة',
|
||||
'todo.addCategory': 'إضافة فئة',
|
||||
'todo.newItem': 'مهمة جديدة',
|
||||
'todo.empty': 'لا توجد مهام بعد. أضف مهمة للبدء!',
|
||||
'todo.filter.my': 'مهامي',
|
||||
'todo.filter.overdue': 'متأخرة',
|
||||
'todo.sidebar.tasks': 'المهام',
|
||||
'todo.sidebar.categories': 'الفئات',
|
||||
'todo.detail.title': 'مهمة',
|
||||
'todo.detail.description': 'وصف',
|
||||
'todo.detail.category': 'فئة',
|
||||
'todo.detail.dueDate': 'تاريخ الاستحقاق',
|
||||
'todo.detail.assignedTo': 'مسند إلى',
|
||||
'todo.detail.delete': 'حذف',
|
||||
'todo.detail.save': 'حفظ التغييرات',
|
||||
'todo.detail.create': 'إنشاء مهمة',
|
||||
'todo.detail.priority': 'الأولوية',
|
||||
'todo.detail.noPriority': 'لا شيء',
|
||||
'todo.sortByPrio': 'الأولوية',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'إصدار جديد متاح',
|
||||
'settings.notificationPreferences.noChannels': 'لم يتم تكوين قنوات إشعارات. اطلب من المسؤول إعداد إشعارات البريد الإلكتروني أو webhook.',
|
||||
'settings.webhookUrl.label': 'رابط Webhook',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'أدخل رابط Webhook الخاص بـ Discord أو Slack أو المخصص لتلقي الإشعارات.',
|
||||
'settings.webhookUrl.save': 'حفظ',
|
||||
'settings.webhookUrl.saved': 'تم حفظ رابط Webhook',
|
||||
'settings.webhookUrl.test': 'اختبار',
|
||||
'settings.webhookUrl.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
|
||||
'settings.webhookUrl.testFailed': 'فشل إرسال Webhook الاختباري',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'الإشعارات داخل التطبيق نشطة دائمًا ولا يمكن تعطيلها بشكل عام.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Webhook المسؤول',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'يُستخدم هذا الـ Webhook حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن Webhooks المستخدمين ويُرسل تلقائيًا عند تعيين رابط URL.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'تم حفظ رابط Webhook المسؤول',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
|
||||
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
|
||||
'admin.tabs.notifications': 'الإشعارات',
|
||||
'notifications.versionAvailable.title': 'تحديث متاح',
|
||||
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
|
||||
'notifications.versionAvailable.button': 'عرض التفاصيل',
|
||||
'notif.test.title': '[اختبار] إشعار',
|
||||
'notif.test.simple.text': 'هذا إشعار اختبار بسيط.',
|
||||
'notif.test.boolean.text': 'هل تقبل هذا الإشعار الاختباري؟',
|
||||
'notif.test.navigate.text': 'انقر أدناه للانتقال إلى لوحة التحكم.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'دعوة للرحلة',
|
||||
'notif.trip_invite.text': '{actor} دعاك إلى {trip}',
|
||||
'notif.booking_change.title': 'تم تحديث الحجز',
|
||||
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
|
||||
'notif.trip_reminder.title': 'تذكير بالرحلة',
|
||||
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
|
||||
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
|
||||
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
|
||||
'notif.photos_shared.title': 'تمت مشاركة الصور',
|
||||
'notif.photos_shared.text': '{actor} شارك {count} صورة في {trip}',
|
||||
'notif.collab_message.title': 'رسالة جديدة',
|
||||
'notif.collab_message.text': '{actor} أرسل رسالة في {trip}',
|
||||
'notif.packing_tagged.title': 'مهمة التعبئة',
|
||||
'notif.packing_tagged.text': '{actor} عيّنك في {category} في {trip}',
|
||||
'notif.version_available.title': 'إصدار جديد متاح',
|
||||
'notif.version_available.text': 'TREK {version} متاح الآن',
|
||||
'notif.action.view_trip': 'عرض الرحلة',
|
||||
'notif.action.view_collab': 'عرض الرسائل',
|
||||
'notif.action.view_packing': 'عرض التعبئة',
|
||||
'notif.action.view_photos': 'عرض الصور',
|
||||
'notif.action.view_vacay': 'عرض Vacay',
|
||||
'notif.action.view_admin': 'الذهاب للإدارة',
|
||||
'notif.action.view': 'عرض',
|
||||
'notif.action.accept': 'قبول',
|
||||
'notif.action.decline': 'رفض',
|
||||
'notif.generic.title': 'إشعار',
|
||||
'notif.generic.text': 'لديك إشعار جديد',
|
||||
'notif.dev.unknown_event.title': '[DEV] حدث غير معروف',
|
||||
'notif.dev.unknown_event.text': 'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default ar
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.edit': 'Editar',
|
||||
'common.add': 'Adicionar',
|
||||
'common.loading': 'Carregando...',
|
||||
'common.import': 'Importar',
|
||||
'common.error': 'Erro',
|
||||
'common.back': 'Voltar',
|
||||
'common.all': 'Todos',
|
||||
@@ -25,6 +26,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Senha',
|
||||
'common.saving': 'Salvando...',
|
||||
'common.saved': 'Salvo',
|
||||
'trips.reminder': 'Lembrete',
|
||||
'trips.reminderNone': 'Nenhum',
|
||||
'trips.reminderDay': 'dia',
|
||||
'trips.reminderDays': 'dias',
|
||||
'trips.reminderCustom': 'Personalizado',
|
||||
'trips.reminderDaysBefore': 'dias antes da partida',
|
||||
'trips.reminderDisabledHint': 'Os lembretes de viagem estão desativados. Ative-os em Admin > Configurações > Notificações.',
|
||||
'common.update': 'Atualizar',
|
||||
'common.change': 'Alterar',
|
||||
'common.uploading': 'Enviando…',
|
||||
@@ -71,7 +80,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.sharedBy': 'Compartilhada por {name}',
|
||||
'dashboard.days': 'Dias',
|
||||
'dashboard.places': 'Lugares',
|
||||
'dashboard.members': 'Parceiros de viagem',
|
||||
'dashboard.archive': 'Arquivar',
|
||||
'dashboard.copyTrip': 'Copiar',
|
||||
'dashboard.copySuffix': 'cópia',
|
||||
'dashboard.restore': 'Restaurar',
|
||||
'dashboard.archived': 'Arquivada',
|
||||
'dashboard.status.ongoing': 'Em andamento',
|
||||
@@ -90,6 +102,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Não foi possível arquivar',
|
||||
'dashboard.toast.restored': 'Viagem restaurada',
|
||||
'dashboard.toast.restoreError': 'Não foi possível restaurar',
|
||||
'dashboard.toast.copied': 'Viagem copiada!',
|
||||
'dashboard.toast.copyError': 'Não foi possível copiar a viagem',
|
||||
'dashboard.confirm.delete': 'Excluir a viagem "{title}"? Todos os lugares e planos serão excluídos permanentemente.',
|
||||
'dashboard.editTrip': 'Editar viagem',
|
||||
'dashboard.createTrip': 'Criar nova viagem',
|
||||
@@ -99,6 +113,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'Sobre o que é esta viagem?',
|
||||
'dashboard.startDate': 'Data de início',
|
||||
'dashboard.endDate': 'Data de término',
|
||||
'dashboard.dayCount': 'Número de dias',
|
||||
'dashboard.dayCountHint': 'Quantos dias planejar quando nenhuma data de viagem for definida.',
|
||||
'dashboard.noDateHint': 'Sem datas — serão criados 7 dias padrão. Você pode alterar depois.',
|
||||
'dashboard.coverImage': 'Imagem de capa',
|
||||
'dashboard.addCoverImage': 'Adicionar capa (ou arrastar e soltar)',
|
||||
@@ -113,6 +129,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Settings
|
||||
'settings.title': 'Configurações',
|
||||
'settings.subtitle': 'Ajuste suas preferências pessoais',
|
||||
'settings.tabs.display': 'Exibição',
|
||||
'settings.tabs.map': 'Mapa',
|
||||
'settings.tabs.notifications': 'Notificações',
|
||||
'settings.tabs.integrations': 'Integrações',
|
||||
'settings.tabs.account': 'Conta',
|
||||
'settings.tabs.about': 'Sobre',
|
||||
'settings.map': 'Mapa',
|
||||
'settings.mapTemplate': 'Modelo de mapa',
|
||||
'settings.mapTemplatePlaceholder.select': 'Selecione o modelo...',
|
||||
@@ -149,9 +171,26 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
|
||||
'settings.notifyPackingTagged': 'Lista de mala: atribuições',
|
||||
'settings.notifyWebhook': 'Notificações webhook',
|
||||
'settings.notificationsDisabled': 'As notificações não estão configuradas. Peça a um administrador para ativar notificações por e-mail ou webhook.',
|
||||
'settings.notificationsActive': 'Canal ativo',
|
||||
'settings.notificationsManagedByAdmin': 'Os eventos de notificação são configurados pelo administrador.',
|
||||
'admin.notifications.title': 'Notificações',
|
||||
'admin.notifications.hint': 'Escolha um canal de notificação. Apenas um pode estar ativo por vez.',
|
||||
'admin.notifications.none': 'Desativado',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Eventos de notificação',
|
||||
'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
|
||||
'admin.notifications.configureFirst': 'Configure primeiro as configurações SMTP ou webhook abaixo, depois ative os eventos.',
|
||||
'admin.notifications.save': 'Salvar configurações de notificação',
|
||||
'admin.notifications.saved': 'Configurações de notificação salvas',
|
||||
'admin.notifications.testWebhook': 'Enviar webhook de teste',
|
||||
'admin.notifications.testWebhookSuccess': 'Webhook de teste enviado com sucesso',
|
||||
'admin.notifications.testWebhookFailed': 'Falha ao enviar webhook de teste',
|
||||
'admin.smtp.title': 'E-mail e notificações',
|
||||
'admin.smtp.hint': 'Configuração SMTP para notificações por e-mail. Opcional: URL webhook para Discord, Slack, etc.',
|
||||
'admin.smtp.hint': 'Configuração SMTP para envio de notificações por e-mail.',
|
||||
'admin.smtp.testButton': 'Enviar e-mail de teste',
|
||||
'admin.webhook.hint': 'Enviar notificações para um webhook externo (Discord, Slack, etc.).',
|
||||
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
|
||||
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
|
||||
'dayplan.icsTooltip': 'Exportar calendário (ICS)',
|
||||
@@ -186,6 +225,15 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.on': 'Ligado',
|
||||
'settings.off': 'Desligado',
|
||||
'settings.account': 'Conta',
|
||||
'settings.about': 'Sobre',
|
||||
'settings.about.reportBug': 'Reportar um bug',
|
||||
'settings.about.reportBugHint': 'Encontrou um problema? Nos avise',
|
||||
'settings.about.featureRequest': 'Solicitar recurso',
|
||||
'settings.about.featureRequestHint': 'Sugira um novo recurso',
|
||||
'settings.about.wikiHint': 'Documentação e guias',
|
||||
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
|
||||
'settings.about.madeWith': 'Feito com',
|
||||
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
|
||||
'settings.username': 'Nome de usuário',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Função',
|
||||
@@ -200,7 +248,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.passwordRequired': 'Informe a senha atual e a nova',
|
||||
'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
|
||||
'settings.passwordMismatch': 'As senhas não coincidem',
|
||||
'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula e número',
|
||||
'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula, número e um caractere especial',
|
||||
'settings.passwordChanged': 'Senha alterada com sucesso',
|
||||
'settings.deleteAccount': 'Excluir conta',
|
||||
'settings.deleteAccountTitle': 'Excluir sua conta?',
|
||||
@@ -221,6 +269,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarError': 'Falha no envio',
|
||||
'settings.mfa.title': 'Autenticação em duas etapas (2FA)',
|
||||
'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).',
|
||||
'settings.mfa.requiredByPolicy': 'O administrador exige autenticação em dois fatores. Configure um app autenticador abaixo antes de continuar.',
|
||||
'settings.mfa.backupTitle': 'Códigos de backup',
|
||||
'settings.mfa.backupDescription': 'Use estes códigos únicos se perder acesso ao app autenticador.',
|
||||
'settings.mfa.backupWarning': 'Salve estes códigos agora. Cada código pode ser usado apenas uma vez.',
|
||||
'settings.mfa.backupCopy': 'Copiar códigos',
|
||||
'settings.mfa.backupDownload': 'Baixar TXT',
|
||||
'settings.mfa.backupPrint': 'Imprimir / PDF',
|
||||
'settings.mfa.backupCopied': 'Códigos de backup copiados',
|
||||
'settings.mfa.enabled': 'O 2FA está ativado na sua conta.',
|
||||
'settings.mfa.disabled': 'O 2FA não está ativado.',
|
||||
'settings.mfa.setup': 'Configurar autenticador',
|
||||
@@ -235,6 +291,32 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada',
|
||||
'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada',
|
||||
'settings.mfa.demoBlocked': 'Indisponível no modo demonstração',
|
||||
'settings.mcp.title': 'Configuração MCP',
|
||||
'settings.mcp.endpoint': 'Endpoint MCP',
|
||||
'settings.mcp.clientConfig': 'Configuração do cliente',
|
||||
'settings.mcp.clientConfigHint': 'Substitua <your_token> por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
|
||||
'settings.mcp.copy': 'Copiar',
|
||||
'settings.mcp.copied': 'Copiado!',
|
||||
'settings.mcp.apiTokens': 'Tokens de API',
|
||||
'settings.mcp.createToken': 'Criar novo token',
|
||||
'settings.mcp.noTokens': 'Nenhum token ainda. Crie um para conectar clientes MCP.',
|
||||
'settings.mcp.tokenCreatedAt': 'Criado em',
|
||||
'settings.mcp.tokenUsedAt': 'Usado em',
|
||||
'settings.mcp.deleteTokenTitle': 'Excluir token',
|
||||
'settings.mcp.deleteTokenMessage': 'Este token deixará de funcionar imediatamente. Qualquer cliente MCP que o utilize perderá o acesso.',
|
||||
'settings.mcp.modal.createTitle': 'Criar token de API',
|
||||
'settings.mcp.modal.tokenName': 'Nome do token',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'ex.: Claude Desktop, Notebook do trabalho',
|
||||
'settings.mcp.modal.creating': 'Criando…',
|
||||
'settings.mcp.modal.create': 'Criar token',
|
||||
'settings.mcp.modal.createdTitle': 'Token criado',
|
||||
'settings.mcp.modal.createdWarning': 'Este token será exibido apenas uma vez. Copie e guarde agora — não poderá ser recuperado.',
|
||||
'settings.mcp.modal.done': 'Concluído',
|
||||
'settings.mcp.toast.created': 'Token criado',
|
||||
'settings.mcp.toast.createError': 'Falha ao criar token',
|
||||
'settings.mcp.toast.deleted': 'Token excluído',
|
||||
'settings.mcp.toast.deleteError': 'Falha ao excluir token',
|
||||
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
|
||||
|
||||
// Login
|
||||
'login.error': 'Falha no login. Verifique suas credenciais.',
|
||||
@@ -263,6 +345,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.signIn': 'Entrar',
|
||||
'login.createAdmin': 'Criar conta de administrador',
|
||||
'login.createAdminHint': 'Configure a primeira conta de administrador do TREK.',
|
||||
'login.setNewPassword': 'Definir nova senha',
|
||||
'login.setNewPasswordHint': 'Você deve alterar sua senha antes de continuar.',
|
||||
'login.createAccount': 'Criar conta',
|
||||
'login.createAccountHint': 'Cadastre uma nova conta.',
|
||||
'login.creating': 'Criando…',
|
||||
@@ -289,7 +373,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'As senhas não coincidem',
|
||||
'register.passwordTooShort': 'A senha deve ter pelo menos 6 caracteres',
|
||||
'register.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
|
||||
'register.failed': 'Falha no cadastro',
|
||||
'register.getStarted': 'Começar',
|
||||
'register.subtitle': 'Crie uma conta e comece a planejar suas viagens.',
|
||||
@@ -364,6 +448,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.settings': 'Configurações',
|
||||
'admin.allowRegistration': 'Permitir cadastro',
|
||||
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
|
||||
'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
|
||||
'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
|
||||
'admin.apiKeys': 'Chaves de API',
|
||||
'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.',
|
||||
'admin.mapsKey': 'Chave da API Google Maps',
|
||||
@@ -394,7 +480,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Rastreamento de malas',
|
||||
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
|
||||
'admin.tabs.config': 'Configuração',
|
||||
'admin.tabs.config': 'Personalização',
|
||||
'admin.tabs.templates': 'Modelos de mala',
|
||||
'admin.packingTemplates.title': 'Modelos de mala',
|
||||
'admin.packingTemplates.subtitle': 'Crie listas de mala reutilizáveis para suas viagens',
|
||||
@@ -420,8 +506,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.subtitle': 'Ative ou desative recursos para personalizar sua experiência no TREK.',
|
||||
'admin.addons.catalog.memories.name': 'Memórias',
|
||||
'admin.addons.catalog.memories.description': 'Álbuns de fotos compartilhados em cada viagem',
|
||||
'admin.addons.catalog.packing.name': 'Mala',
|
||||
'admin.addons.catalog.packing.description': 'Listas para preparar a bagagem de cada viagem',
|
||||
'admin.addons.catalog.packing.name': 'Listas',
|
||||
'admin.addons.catalog.packing.description': 'Listas de bagagem e tarefas a fazer para suas viagens',
|
||||
'admin.addons.catalog.budget.name': 'Orçamento',
|
||||
'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem',
|
||||
'admin.addons.catalog.documents.name': 'Documentos',
|
||||
@@ -432,17 +518,21 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
|
||||
'admin.addons.catalog.collab.name': 'Colab',
|
||||
'admin.addons.catalog.collab.description': 'Notas, enquetes e chat em tempo real para planejar a viagem',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA',
|
||||
'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ',
|
||||
'admin.addons.subtitleAfter': ' experiência.',
|
||||
'admin.addons.enabled': 'Ativado',
|
||||
'admin.addons.disabled': 'Desativado',
|
||||
'admin.addons.type.trip': 'Viagem',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.integration': 'Integração',
|
||||
'admin.addons.tripHint': 'Disponível como aba em cada viagem',
|
||||
'admin.addons.globalHint': 'Disponível como seção própria na navegação principal',
|
||||
'admin.addons.toast.updated': 'Complemento atualizado',
|
||||
'admin.addons.toast.error': 'Falha ao atualizar complemento',
|
||||
'admin.addons.noAddons': 'Nenhum complemento disponível',
|
||||
'admin.addons.integrationHint': 'Serviços de backend e integrações de API sem página dedicada',
|
||||
// Weather info
|
||||
'admin.weather.title': 'Dados meteorológicos',
|
||||
'admin.weather.badge': 'Desde 24 de março de 2026',
|
||||
@@ -455,7 +545,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.weather.requestsDesc': 'Grátis, sem chave de API',
|
||||
'admin.weather.locationHint': 'O clima usa o primeiro lugar com coordenadas de cada dia. Se nenhum lugar estiver atribuído ao dia, qualquer lugar da lista serve como referência.',
|
||||
|
||||
'admin.tabs.audit': 'Log de auditoria',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
|
||||
'admin.audit.subtitle': 'Eventos sensíveis de segurança e administração (backups, usuários, 2FA, configurações).',
|
||||
'admin.audit.empty': 'Nenhum registro de auditoria.',
|
||||
@@ -505,7 +595,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Planeje e gerencie dias de férias',
|
||||
'vacay.settings': 'Configurações',
|
||||
'vacay.year': 'Ano',
|
||||
'vacay.addYear': 'Adicionar ano',
|
||||
'vacay.addYear': 'Adicionar próximo ano',
|
||||
'vacay.addPrevYear': 'Adicionar ano anterior',
|
||||
'vacay.removeYear': 'Remover ano',
|
||||
'vacay.removeYearConfirm': 'Remover {year}?',
|
||||
'vacay.removeYearHint': 'Todas as entradas de férias e feriados da empresa deste ano serão excluídas permanentemente.',
|
||||
@@ -597,10 +688,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Remover',
|
||||
'atlas.confirmMark': 'Marcar este país como visitado?',
|
||||
'atlas.confirmUnmark': 'Remover este país da lista de visitados?',
|
||||
'atlas.confirmUnmarkRegion': 'Remover esta região da lista de visitados?',
|
||||
'atlas.markVisited': 'Marcar como visitado',
|
||||
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
|
||||
'atlas.markRegionVisitedHint': 'Adicionar esta região à lista de visitados',
|
||||
'atlas.addToBucket': 'Adicionar à lista de desejos',
|
||||
'atlas.addPoi': 'Adicionar lugar',
|
||||
'atlas.searchCountry': 'Buscar um país...',
|
||||
'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)',
|
||||
'atlas.month': 'Mês',
|
||||
'atlas.year': 'Ano',
|
||||
@@ -609,7 +703,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.statsTab': 'Estatísticas',
|
||||
'atlas.bucketTab': 'Lista de desejos',
|
||||
'atlas.addBucket': 'Adicionar à lista de desejos',
|
||||
'atlas.bucketNamePlaceholder': 'Lugar ou destino...',
|
||||
'atlas.bucketNotesPlaceholder': 'Notas (opcional)',
|
||||
'atlas.bucketEmpty': 'Sua lista de desejos está vazia',
|
||||
'atlas.bucketEmptyHint': 'Adicione lugares que sonha em visitar',
|
||||
@@ -622,7 +715,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.nextTrip': 'Próxima viagem',
|
||||
'atlas.daysLeft': 'dias restantes',
|
||||
'atlas.streak': 'Sequência',
|
||||
'atlas.year': 'ano',
|
||||
'atlas.years': 'anos',
|
||||
'atlas.yearInRow': 'ano seguido',
|
||||
'atlas.yearsInRow': 'anos seguidos',
|
||||
@@ -649,6 +741,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de mala',
|
||||
'trip.tabs.packingShort': 'Mala',
|
||||
'trip.tabs.lists': 'Listas',
|
||||
'trip.tabs.listsShort': 'Listas',
|
||||
'trip.tabs.budget': 'Orçamento',
|
||||
'trip.tabs.files': 'Arquivos',
|
||||
'trip.loading': 'Carregando viagem...',
|
||||
@@ -664,6 +758,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reserva adicionada',
|
||||
'trip.toast.deleted': 'Excluído',
|
||||
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
|
||||
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Nenhum lugar planejado para este dia',
|
||||
@@ -698,9 +793,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Adicionar lugar/atividade',
|
||||
'places.importGpx': 'Importar GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} lugares importados do GPX',
|
||||
'places.gpxError': 'Falha ao importar GPX',
|
||||
'places.importGoogleList': 'Lista Google',
|
||||
'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
|
||||
'places.googleListImported': '{count} lugares importados de "{list}"',
|
||||
'places.googleListError': 'Falha ao importar lista do Google Maps',
|
||||
'places.viewDetails': 'Ver detalhes',
|
||||
'places.urlResolved': 'Lugar importado da URL',
|
||||
'places.assignToDay': 'Adicionar a qual dia?',
|
||||
'places.all': 'Todos',
|
||||
@@ -757,6 +857,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'Reserva',
|
||||
'inspector.editRes': 'Editar reserva',
|
||||
'inspector.participants': 'Participantes',
|
||||
'inspector.trackStats': 'Dados da trilha',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Reservas',
|
||||
@@ -836,9 +937,36 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.linkAssignment': 'Vincular à atribuição do dia',
|
||||
'reservations.pickAssignment': 'Selecione uma atribuição do seu plano...',
|
||||
'reservations.noAssignment': 'Sem vínculo (avulsa)',
|
||||
'reservations.price': 'Preço',
|
||||
'reservations.budgetCategory': 'Categoria de orçamento',
|
||||
'reservations.budgetCategoryPlaceholder': 'ex. Transporte, Acomodação',
|
||||
'reservations.budgetCategoryAuto': 'Automático (pelo tipo de reserva)',
|
||||
'reservations.budgetHint': 'Uma entrada de orçamento será criada automaticamente ao salvar.',
|
||||
'reservations.departureDate': 'Partida',
|
||||
'reservations.arrivalDate': 'Chegada',
|
||||
'reservations.departureTime': 'Hora partida',
|
||||
'reservations.arrivalTime': 'Hora chegada',
|
||||
'reservations.pickupDate': 'Retirada',
|
||||
'reservations.returnDate': 'Devolução',
|
||||
'reservations.pickupTime': 'Hora retirada',
|
||||
'reservations.returnTime': 'Hora devolução',
|
||||
'reservations.endDate': 'Data final',
|
||||
'reservations.meta.departureTimezone': 'TZ partida',
|
||||
'reservations.meta.arrivalTimezone': 'TZ chegada',
|
||||
'reservations.span.departure': 'Partida',
|
||||
'reservations.span.arrival': 'Chegada',
|
||||
'reservations.span.inTransit': 'Em trânsito',
|
||||
'reservations.span.pickup': 'Retirada',
|
||||
'reservations.span.return': 'Devolução',
|
||||
'reservations.span.active': 'Ativo',
|
||||
'reservations.span.start': 'Início',
|
||||
'reservations.span.end': 'Fim',
|
||||
'reservations.span.ongoing': 'Em andamento',
|
||||
'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Orçamento',
|
||||
'budget.exportCsv': 'Exportar CSV',
|
||||
'budget.emptyTitle': 'Nenhum orçamento criado ainda',
|
||||
'budget.emptyText': 'Crie categorias e lançamentos para planejar o orçamento da viagem',
|
||||
'budget.emptyPlaceholder': 'Nome da categoria...',
|
||||
@@ -853,6 +981,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.table.perDay': 'Por dia',
|
||||
'budget.table.perPersonDay': 'P. p. / dia',
|
||||
'budget.table.note': 'Obs.',
|
||||
'budget.table.date': 'Data',
|
||||
'budget.newEntry': 'Novo lançamento',
|
||||
'budget.defaultEntry': 'Novo lançamento',
|
||||
'budget.defaultCategory': 'Nova categoria',
|
||||
@@ -1247,6 +1376,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.chat.today': 'Hoje',
|
||||
'collab.chat.yesterday': 'Ontem',
|
||||
'collab.chat.deletedMessage': 'apagou uma mensagem',
|
||||
'collab.chat.reply': 'Responder',
|
||||
'collab.chat.loadMore': 'Carregar mensagens antigas',
|
||||
'collab.chat.justNow': 'agora mesmo',
|
||||
'collab.chat.minutesAgo': 'há {n} min',
|
||||
@@ -1315,12 +1445,19 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichUrl': 'URL do servidor Immich',
|
||||
'memories.immichApiKey': 'Chave da API',
|
||||
'memories.testConnection': 'Testar conexão',
|
||||
'memories.testFirst': 'Teste a conexão primeiro',
|
||||
'memories.connected': 'Conectado',
|
||||
'memories.disconnected': 'Não conectado',
|
||||
'memories.connectionSuccess': 'Conectado ao Immich',
|
||||
'memories.connectionError': 'Não foi possível conectar ao Immich',
|
||||
'memories.saved': 'Configurações do Immich salvas',
|
||||
'memories.addPhotos': 'Adicionar fotos',
|
||||
'memories.linkAlbum': 'Vincular álbum',
|
||||
'memories.selectAlbum': 'Selecionar álbum do Immich',
|
||||
'memories.noAlbums': 'Nenhum álbum encontrado',
|
||||
'memories.syncAlbum': 'Sincronizar álbum',
|
||||
'memories.unlinkAlbum': 'Desvincular',
|
||||
'memories.photos': 'fotos',
|
||||
'memories.selectPhotos': 'Selecionar fotos do Immich',
|
||||
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
|
||||
'memories.selected': 'selecionadas',
|
||||
@@ -1336,6 +1473,218 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.confirmShareTitle': 'Compartilhar com membros da viagem?',
|
||||
'memories.confirmShareHint': '{count} fotos serão visíveis para todos os membros desta viagem. Você pode tornar fotos individuais privadas depois.',
|
||||
'memories.confirmShareButton': 'Compartilhar fotos',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Permissões',
|
||||
'admin.tabs.mcpTokens': 'Tokens MCP',
|
||||
'admin.mcpTokens.title': 'Tokens MCP',
|
||||
'admin.mcpTokens.subtitle': 'Gerenciar tokens de API de todos os usuários',
|
||||
'admin.mcpTokens.owner': 'Proprietário',
|
||||
'admin.mcpTokens.tokenName': 'Nome do Token',
|
||||
'admin.mcpTokens.created': 'Criado',
|
||||
'admin.mcpTokens.lastUsed': 'Último uso',
|
||||
'admin.mcpTokens.never': 'Nunca',
|
||||
'admin.mcpTokens.empty': 'Nenhum token MCP foi criado ainda',
|
||||
'admin.mcpTokens.deleteTitle': 'Excluir Token',
|
||||
'admin.mcpTokens.deleteMessage': 'Isso revogará o token imediatamente. O usuário perderá o acesso MCP por este token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token excluído',
|
||||
'admin.mcpTokens.deleteError': 'Falha ao excluir token',
|
||||
'admin.mcpTokens.loadError': 'Falha ao carregar tokens',
|
||||
'perm.title': 'Configurações de Permissões',
|
||||
'perm.subtitle': 'Controle quem pode realizar ações no aplicativo',
|
||||
'perm.saved': 'Configurações de permissões salvas',
|
||||
'perm.resetDefaults': 'Restaurar padrões',
|
||||
'perm.customized': 'personalizado',
|
||||
'perm.level.admin': 'Apenas administrador',
|
||||
'perm.level.tripOwner': 'Dono da viagem',
|
||||
'perm.level.tripMember': 'Membros da viagem',
|
||||
'perm.level.everybody': 'Todos',
|
||||
'perm.cat.trip': 'Gerenciamento de Viagens',
|
||||
'perm.cat.members': 'Gerenciamento de Membros',
|
||||
'perm.cat.files': 'Arquivos',
|
||||
'perm.cat.content': 'Conteúdo e Cronograma',
|
||||
'perm.cat.extras': 'Orçamento, Bagagem e Colaboração',
|
||||
'perm.action.trip_create': 'Criar viagens',
|
||||
'perm.action.trip_edit': 'Editar detalhes da viagem',
|
||||
'perm.action.trip_delete': 'Excluir viagens',
|
||||
'perm.action.trip_archive': 'Arquivar / desarquivar viagens',
|
||||
'perm.action.trip_cover_upload': 'Enviar imagem de capa',
|
||||
'perm.action.member_manage': 'Adicionar / remover membros',
|
||||
'perm.action.file_upload': 'Enviar arquivos',
|
||||
'perm.action.file_edit': 'Editar metadados do arquivo',
|
||||
'perm.action.file_delete': 'Excluir arquivos',
|
||||
'perm.action.place_edit': 'Adicionar / editar / excluir lugares',
|
||||
'perm.action.day_edit': 'Editar dias, notas e atribuições',
|
||||
'perm.action.reservation_edit': 'Gerenciar reservas',
|
||||
'perm.action.budget_edit': 'Gerenciar orçamento',
|
||||
'perm.action.packing_edit': 'Gerenciar listas de bagagem',
|
||||
'perm.action.collab_edit': 'Colaboração (notas, enquetes, chat)',
|
||||
'perm.action.share_manage': 'Gerenciar links de compartilhamento',
|
||||
'perm.actionHint.trip_create': 'Quem pode criar novas viagens',
|
||||
'perm.actionHint.trip_edit': 'Quem pode alterar nome, datas, descrição e moeda da viagem',
|
||||
'perm.actionHint.trip_delete': 'Quem pode excluir permanentemente uma viagem',
|
||||
'perm.actionHint.trip_archive': 'Quem pode arquivar ou desarquivar uma viagem',
|
||||
'perm.actionHint.trip_cover_upload': 'Quem pode enviar ou alterar a imagem de capa',
|
||||
'perm.actionHint.member_manage': 'Quem pode convidar ou remover membros da viagem',
|
||||
'perm.actionHint.file_upload': 'Quem pode enviar arquivos para uma viagem',
|
||||
'perm.actionHint.file_edit': 'Quem pode editar descrições e links dos arquivos',
|
||||
'perm.actionHint.file_delete': 'Quem pode mover arquivos para a lixeira ou excluí-los permanentemente',
|
||||
'perm.actionHint.place_edit': 'Quem pode adicionar, editar ou excluir lugares',
|
||||
'perm.actionHint.day_edit': 'Quem pode editar dias, notas dos dias e atribuições de lugares',
|
||||
'perm.actionHint.reservation_edit': 'Quem pode criar, editar ou excluir reservas',
|
||||
'perm.actionHint.budget_edit': 'Quem pode criar, editar ou excluir itens do orçamento',
|
||||
'perm.actionHint.packing_edit': 'Quem pode gerenciar itens de bagagem e malas',
|
||||
'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens',
|
||||
'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos',
|
||||
// Undo
|
||||
'undo.button': 'Desfazer',
|
||||
'undo.tooltip': 'Desfazer: {action}',
|
||||
'undo.assignPlace': 'Local atribuído ao dia',
|
||||
'undo.removeAssignment': 'Local removido do dia',
|
||||
'undo.reorder': 'Locais reordenados',
|
||||
'undo.optimize': 'Rota otimizada',
|
||||
'undo.deletePlace': 'Local excluído',
|
||||
'undo.moveDay': 'Local movido para outro dia',
|
||||
'undo.lock': 'Bloqueio do local alternado',
|
||||
'undo.importGpx': 'Importação de GPX',
|
||||
'undo.importGoogleList': 'Importação do Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Notificações',
|
||||
'notifications.markAllRead': 'Marcar tudo como lido',
|
||||
'notifications.deleteAll': 'Excluir tudo',
|
||||
'notifications.showAll': 'Ver todas as notificações',
|
||||
'notifications.empty': 'Sem notificações',
|
||||
'notifications.emptyDescription': 'Você está em dia!',
|
||||
'notifications.all': 'Todas',
|
||||
'notifications.unreadOnly': 'Não lidas',
|
||||
'notifications.markRead': 'Marcar como lido',
|
||||
'notifications.markUnread': 'Marcar como não lido',
|
||||
'notifications.delete': 'Excluir',
|
||||
'notifications.system': 'Sistema',
|
||||
'memories.error.loadAlbums': 'Falha ao carregar álbuns',
|
||||
'memories.error.linkAlbum': 'Falha ao vincular álbum',
|
||||
'memories.error.unlinkAlbum': 'Falha ao desvincular álbum',
|
||||
'memories.error.syncAlbum': 'Falha ao sincronizar álbum',
|
||||
'memories.error.loadPhotos': 'Falha ao carregar fotos',
|
||||
'memories.error.addPhotos': 'Falha ao adicionar fotos',
|
||||
'memories.error.removePhoto': 'Falha ao remover foto',
|
||||
'memories.error.toggleSharing': 'Falha ao atualizar compartilhamento',
|
||||
'undo.addPlace': 'Local adicionado',
|
||||
'undo.done': 'Desfeito: {action}',
|
||||
'notifications.test.title': 'Notificação de teste de {actor}',
|
||||
'notifications.test.text': 'Esta é uma notificação de teste simples.',
|
||||
'notifications.test.booleanTitle': '{actor} solicita sua aprovação',
|
||||
'notifications.test.booleanText': 'Notificação de teste booleana.',
|
||||
'notifications.test.accept': 'Aprovar',
|
||||
'notifications.test.decline': 'Recusar',
|
||||
'notifications.test.navigateTitle': 'Confira algo',
|
||||
'notifications.test.navigateText': 'Notificação de teste de navegação.',
|
||||
'notifications.test.goThere': 'Ir lá',
|
||||
'notifications.test.adminTitle': 'Transmissão do admin',
|
||||
'notifications.test.adminText': '{actor} enviou uma notificação de teste para todos os admins.',
|
||||
'notifications.test.tripTitle': '{actor} postou na sua viagem',
|
||||
'notifications.test.tripText': 'Notificação de teste para a viagem "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Lista de bagagem',
|
||||
'todo.subtab.todo': 'A fazer',
|
||||
'todo.completed': 'concluído(s)',
|
||||
'todo.filter.all': 'Todos',
|
||||
'todo.filter.open': 'Aberto',
|
||||
'todo.filter.done': 'Concluído',
|
||||
'todo.uncategorized': 'Sem categoria',
|
||||
'todo.namePlaceholder': 'Nome da tarefa',
|
||||
'todo.descriptionPlaceholder': 'Descrição (opcional)',
|
||||
'todo.unassigned': 'Não atribuído',
|
||||
'todo.noCategory': 'Sem categoria',
|
||||
'todo.hasDescription': 'Com descrição',
|
||||
'todo.addItem': 'Adicionar nova tarefa...',
|
||||
'todo.newCategory': 'Nome da categoria',
|
||||
'todo.addCategory': 'Adicionar categoria',
|
||||
'todo.newItem': 'Nova tarefa',
|
||||
'todo.empty': 'Nenhuma tarefa ainda. Adicione uma tarefa para começar!',
|
||||
'todo.filter.my': 'Minhas tarefas',
|
||||
'todo.filter.overdue': 'Atrasada',
|
||||
'todo.sidebar.tasks': 'Tarefas',
|
||||
'todo.sidebar.categories': 'Categorias',
|
||||
'todo.detail.title': 'Tarefa',
|
||||
'todo.detail.description': 'Descrição',
|
||||
'todo.detail.category': 'Categoria',
|
||||
'todo.detail.dueDate': 'Data de vencimento',
|
||||
'todo.detail.assignedTo': 'Atribuído a',
|
||||
'todo.detail.delete': 'Excluir',
|
||||
'todo.detail.save': 'Salvar alterações',
|
||||
'todo.detail.create': 'Criar tarefa',
|
||||
'todo.detail.priority': 'Prioridade',
|
||||
'todo.detail.noPriority': 'Nenhuma',
|
||||
'todo.sortByPrio': 'Prioridade',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'Nova versão disponível',
|
||||
'settings.notificationPreferences.noChannels': 'Nenhum canal de notificação configurado. Peça a um administrador para configurar notificações por e-mail ou webhook.',
|
||||
'settings.webhookUrl.label': 'URL do webhook',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Insira a URL do seu webhook do Discord, Slack ou personalizado para receber notificações.',
|
||||
'settings.webhookUrl.save': 'Salvar',
|
||||
'settings.webhookUrl.saved': 'URL do webhook salva',
|
||||
'settings.webhookUrl.test': 'Testar',
|
||||
'settings.webhookUrl.testSuccess': 'Webhook de teste enviado com sucesso',
|
||||
'settings.webhookUrl.testFailed': 'Falha no webhook de teste',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'As notificações no aplicativo estão sempre ativas e não podem ser desativadas globalmente.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Webhook de admin',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Este webhook é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos webhooks de usuários e dispara automaticamente quando uma URL está configurada.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'URL do webhook de admin salva',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de teste enviado com sucesso',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Falha no webhook de teste',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'O webhook de admin dispara automaticamente quando uma URL está configurada',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
|
||||
'admin.tabs.notifications': 'Notificações',
|
||||
'notifications.versionAvailable.title': 'Atualização disponível',
|
||||
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
|
||||
'notifications.versionAvailable.button': 'Ver detalhes',
|
||||
'notif.test.title': '[Teste] Notificação',
|
||||
'notif.test.simple.text': 'Esta é uma notificação de teste simples.',
|
||||
'notif.test.boolean.text': 'Você aceita esta notificação de teste?',
|
||||
'notif.test.navigate.text': 'Clique abaixo para ir ao painel.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Convite para viagem',
|
||||
'notif.trip_invite.text': '{actor} convidou você para {trip}',
|
||||
'notif.booking_change.title': 'Reserva atualizada',
|
||||
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
|
||||
'notif.trip_reminder.title': 'Lembrete de viagem',
|
||||
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
|
||||
'notif.vacay_invite.title': 'Convite Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
|
||||
'notif.photos_shared.title': 'Fotos compartilhadas',
|
||||
'notif.photos_shared.text': '{actor} compartilhou {count} foto(s) em {trip}',
|
||||
'notif.collab_message.title': 'Nova mensagem',
|
||||
'notif.collab_message.text': '{actor} enviou uma mensagem em {trip}',
|
||||
'notif.packing_tagged.title': 'Atribuição de bagagem',
|
||||
'notif.packing_tagged.text': '{actor} atribuiu você a {category} em {trip}',
|
||||
'notif.version_available.title': 'Nova versão disponível',
|
||||
'notif.version_available.text': 'TREK {version} está disponível',
|
||||
'notif.action.view_trip': 'Ver viagem',
|
||||
'notif.action.view_collab': 'Ver mensagens',
|
||||
'notif.action.view_packing': 'Ver bagagem',
|
||||
'notif.action.view_photos': 'Ver fotos',
|
||||
'notif.action.view_vacay': 'Ver Vacay',
|
||||
'notif.action.view_admin': 'Ir para admin',
|
||||
'notif.action.view': 'Ver',
|
||||
'notif.action.accept': 'Aceitar',
|
||||
'notif.action.decline': 'Recusar',
|
||||
'notif.generic.title': 'Notificação',
|
||||
'notif.generic.text': 'Você tem uma nova notificação',
|
||||
'notif.dev.unknown_event.title': '[DEV] Evento desconhecido',
|
||||
'notif.dev.unknown_event.text': 'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default br
|
||||
|
||||
|
||||
+1695
-1345
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.edit': 'Bearbeiten',
|
||||
'common.add': 'Hinzufügen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importieren',
|
||||
'common.error': 'Fehler',
|
||||
'common.back': 'Zurück',
|
||||
'common.all': 'Alle',
|
||||
@@ -25,6 +26,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.email': 'E-Mail',
|
||||
'common.password': 'Passwort',
|
||||
'common.saving': 'Speichern...',
|
||||
'common.saved': 'Gespeichert',
|
||||
'trips.reminder': 'Erinnerung',
|
||||
'trips.reminderNone': 'Keine',
|
||||
'trips.reminderDay': 'Tag',
|
||||
'trips.reminderDays': 'Tage',
|
||||
'trips.reminderCustom': 'Benutzerdefiniert',
|
||||
'trips.reminderDaysBefore': 'Tage vor Abreise',
|
||||
'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.',
|
||||
'common.update': 'Aktualisieren',
|
||||
'common.change': 'Ändern',
|
||||
'common.uploading': 'Hochladen…',
|
||||
@@ -71,7 +80,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.sharedBy': 'Geteilt von {name}',
|
||||
'dashboard.days': 'Tage',
|
||||
'dashboard.places': 'Orte',
|
||||
'dashboard.members': 'Reise-Buddies',
|
||||
'dashboard.archive': 'Archivieren',
|
||||
'dashboard.copyTrip': 'Kopieren',
|
||||
'dashboard.copySuffix': 'Kopie',
|
||||
'dashboard.restore': 'Wiederherstellen',
|
||||
'dashboard.archived': 'Archiviert',
|
||||
'dashboard.status.ongoing': 'Laufend',
|
||||
@@ -90,6 +102,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Fehler beim Archivieren',
|
||||
'dashboard.toast.restored': 'Reise wiederhergestellt',
|
||||
'dashboard.toast.restoreError': 'Fehler beim Wiederherstellen',
|
||||
'dashboard.toast.copied': 'Reise kopiert!',
|
||||
'dashboard.toast.copyError': 'Fehler beim Kopieren der Reise',
|
||||
'dashboard.confirm.delete': 'Reise "{title}" löschen? Alle Orte und Pläne werden unwiderruflich gelöscht.',
|
||||
'dashboard.editTrip': 'Reise bearbeiten',
|
||||
'dashboard.createTrip': 'Neue Reise erstellen',
|
||||
@@ -99,6 +113,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'Worum geht es bei dieser Reise?',
|
||||
'dashboard.startDate': 'Startdatum',
|
||||
'dashboard.endDate': 'Enddatum',
|
||||
'dashboard.dayCount': 'Anzahl Tage',
|
||||
'dashboard.dayCountHint': 'Wie viele Tage geplant werden sollen, wenn kein Reisezeitraum gesetzt ist.',
|
||||
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
|
||||
'dashboard.coverImage': 'Titelbild',
|
||||
'dashboard.addCoverImage': 'Titelbild hinzufügen (oder per Drag & Drop)',
|
||||
@@ -113,6 +129,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Settings
|
||||
'settings.title': 'Einstellungen',
|
||||
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
||||
'settings.tabs.display': 'Anzeige',
|
||||
'settings.tabs.map': 'Karte',
|
||||
'settings.tabs.notifications': 'Benachrichtigungen',
|
||||
'settings.tabs.integrations': 'Integrationen',
|
||||
'settings.tabs.account': 'Konto',
|
||||
'settings.tabs.about': 'Über',
|
||||
'settings.map': 'Karte',
|
||||
'settings.mapTemplate': 'Karten-Vorlage',
|
||||
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
|
||||
@@ -149,9 +171,26 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
|
||||
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
|
||||
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
|
||||
'settings.notificationsDisabled': 'Benachrichtigungen sind nicht konfiguriert. Bitten Sie einen Administrator, E-Mail- oder Webhook-Benachrichtungen zu aktivieren.',
|
||||
'settings.notificationsActive': 'Aktiver Kanal',
|
||||
'settings.notificationsManagedByAdmin': 'Benachrichtigungsereignisse werden vom Administrator konfiguriert.',
|
||||
'admin.notifications.title': 'Benachrichtigungen',
|
||||
'admin.notifications.hint': 'Wählen Sie einen Benachrichtigungskanal. Es kann nur einer gleichzeitig aktiv sein.',
|
||||
'admin.notifications.none': 'Deaktiviert',
|
||||
'admin.notifications.email': 'E-Mail (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Benachrichtigungsereignisse',
|
||||
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
|
||||
'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.',
|
||||
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
|
||||
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
|
||||
'admin.notifications.testWebhook': 'Test-Webhook senden',
|
||||
'admin.notifications.testWebhookSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||
'admin.notifications.testWebhookFailed': 'Test-Webhook fehlgeschlagen',
|
||||
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
|
||||
'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.',
|
||||
'admin.smtp.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
|
||||
'admin.smtp.testButton': 'Test-E-Mail senden',
|
||||
'admin.webhook.hint': 'Benachrichtigungen an einen externen Webhook senden (Discord, Slack usw.).',
|
||||
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
|
||||
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
|
||||
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
|
||||
@@ -185,13 +224,48 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'An',
|
||||
'settings.off': 'Aus',
|
||||
'settings.mcp.title': 'MCP-Konfiguration',
|
||||
'settings.mcp.endpoint': 'MCP-Endpunkt',
|
||||
'settings.mcp.clientConfig': 'Client-Konfiguration',
|
||||
'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
|
||||
'settings.mcp.copy': 'Kopieren',
|
||||
'settings.mcp.copied': 'Kopiert!',
|
||||
'settings.mcp.apiTokens': 'API-Tokens',
|
||||
'settings.mcp.createToken': 'Neuen Token erstellen',
|
||||
'settings.mcp.noTokens': 'Noch keine Tokens. Erstelle einen, um MCP-Clients zu verbinden.',
|
||||
'settings.mcp.tokenCreatedAt': 'Erstellt',
|
||||
'settings.mcp.tokenUsedAt': 'Verwendet',
|
||||
'settings.mcp.deleteTokenTitle': 'Token löschen',
|
||||
'settings.mcp.deleteTokenMessage': 'Dieser Token wird sofort ungültig. Jeder MCP-Client, der ihn verwendet, verliert den Zugang.',
|
||||
'settings.mcp.modal.createTitle': 'API-Token erstellen',
|
||||
'settings.mcp.modal.tokenName': 'Token-Name',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'z. B. Claude Desktop, Arbeits-Laptop',
|
||||
'settings.mcp.modal.creating': 'Wird erstellt…',
|
||||
'settings.mcp.modal.create': 'Token erstellen',
|
||||
'settings.mcp.modal.createdTitle': 'Token erstellt',
|
||||
'settings.mcp.modal.createdWarning': 'Dieser Token wird nur einmal angezeigt. Kopiere und speichere ihn jetzt — er kann nicht wiederhergestellt werden.',
|
||||
'settings.mcp.modal.done': 'Fertig',
|
||||
'settings.mcp.toast.created': 'Token erstellt',
|
||||
'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden',
|
||||
'settings.mcp.toast.deleted': 'Token gelöscht',
|
||||
'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden',
|
||||
'settings.account': 'Konto',
|
||||
'settings.about': 'Über',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
'settings.about.reportBugHint': 'Problem gefunden? Melde es uns',
|
||||
'settings.about.featureRequest': 'Feature vorschlagen',
|
||||
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
|
||||
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
|
||||
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
|
||||
'settings.about.madeWith': 'Entwickelt mit',
|
||||
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
|
||||
'settings.username': 'Benutzername',
|
||||
'settings.email': 'E-Mail',
|
||||
'settings.role': 'Rolle',
|
||||
'settings.roleAdmin': 'Administrator',
|
||||
'settings.oidcLinked': 'Verknüpft mit',
|
||||
'settings.changePassword': 'Passwort ändern',
|
||||
'settings.mustChangePassword': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können. Bitte legen Sie unten ein neues Passwort fest.',
|
||||
'settings.currentPassword': 'Aktuelles Passwort',
|
||||
'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt',
|
||||
'settings.newPassword': 'Neues Passwort',
|
||||
@@ -200,7 +274,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
|
||||
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten',
|
||||
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten',
|
||||
'settings.passwordChanged': 'Passwort erfolgreich geändert',
|
||||
'settings.deleteAccount': 'Löschen',
|
||||
'settings.deleteAccountTitle': 'Account wirklich löschen?',
|
||||
@@ -221,6 +295,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarError': 'Fehler beim Hochladen',
|
||||
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
|
||||
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
|
||||
'settings.mfa.requiredByPolicy': 'Dein Administrator verlangt Zwei-Faktor-Authentifizierung. Richte unten eine Authenticator-App ein, bevor du fortfährst.',
|
||||
'settings.mfa.backupTitle': 'Backup-Codes',
|
||||
'settings.mfa.backupDescription': 'Verwende diese Einmal-Codes, wenn du keinen Zugriff mehr auf deine Authenticator-App hast.',
|
||||
'settings.mfa.backupWarning': 'Jetzt speichern. Jeder Code kann nur einmal verwendet werden.',
|
||||
'settings.mfa.backupCopy': 'Codes kopieren',
|
||||
'settings.mfa.backupDownload': 'TXT herunterladen',
|
||||
'settings.mfa.backupPrint': 'Drucken / PDF',
|
||||
'settings.mfa.backupCopied': 'Backup-Codes kopiert',
|
||||
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
|
||||
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
|
||||
'settings.mfa.setup': 'Authenticator einrichten',
|
||||
@@ -263,6 +345,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.signIn': 'Anmelden',
|
||||
'login.createAdmin': 'Admin-Konto erstellen',
|
||||
'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.',
|
||||
'login.setNewPassword': 'Neues Passwort festlegen',
|
||||
'login.setNewPasswordHint': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können.',
|
||||
'login.createAccount': 'Konto erstellen',
|
||||
'login.createAccountHint': 'Neues Konto registrieren.',
|
||||
'login.creating': 'Erstelle…',
|
||||
@@ -289,7 +373,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||
'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||
'register.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||
'register.failed': 'Registrierung fehlgeschlagen',
|
||||
'register.getStarted': 'Jetzt starten',
|
||||
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',
|
||||
@@ -315,7 +399,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'Benutzer',
|
||||
'admin.tabs.categories': 'Kategorien',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.tabs.audit': 'Audit-Protokoll',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
'admin.stats.users': 'Benutzer',
|
||||
'admin.stats.trips': 'Reisen',
|
||||
'admin.stats.places': 'Orte',
|
||||
@@ -365,6 +449,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.settings': 'Einstellungen',
|
||||
'admin.allowRegistration': 'Registrierung erlauben',
|
||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||
'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen',
|
||||
'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.',
|
||||
'admin.apiKeys': 'API-Schlüssel',
|
||||
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
|
||||
'admin.mapsKey': 'Google Maps API Key',
|
||||
@@ -395,7 +481,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Gepäck-Tracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
||||
'admin.tabs.config': 'Konfiguration',
|
||||
'admin.tabs.config': 'Personalisierung',
|
||||
'admin.tabs.templates': 'Packvorlagen',
|
||||
'admin.packingTemplates.title': 'Packvorlagen',
|
||||
'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen',
|
||||
@@ -419,8 +505,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
|
||||
'admin.addons.catalog.packing.name': 'Packliste',
|
||||
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
|
||||
'admin.addons.catalog.packing.name': 'Listen',
|
||||
'admin.addons.catalog.packing.description': 'Packlisten und To-Do-Aufgaben für deine Reisen',
|
||||
'admin.addons.catalog.budget.name': 'Budget',
|
||||
'admin.addons.catalog.budget.description': 'Ausgaben verfolgen und Reisebudget planen',
|
||||
'admin.addons.catalog.documents.name': 'Dokumente',
|
||||
@@ -433,14 +519,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol für die KI-Assistenten-Integration',
|
||||
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
||||
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
||||
'admin.addons.enabled': 'Aktiviert',
|
||||
'admin.addons.disabled': 'Deaktiviert',
|
||||
'admin.addons.type.trip': 'Trip',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.integration': 'Integration',
|
||||
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
|
||||
'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation',
|
||||
'admin.addons.integrationHint': 'Backend-Dienste und API-Integrationen ohne eigene Seite',
|
||||
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
||||
@@ -456,6 +546,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
|
||||
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'MCP-Tokens',
|
||||
'admin.mcpTokens.title': 'MCP-Tokens',
|
||||
'admin.mcpTokens.subtitle': 'API-Tokens aller Benutzer verwalten',
|
||||
'admin.mcpTokens.owner': 'Besitzer',
|
||||
'admin.mcpTokens.tokenName': 'Token-Name',
|
||||
'admin.mcpTokens.created': 'Erstellt',
|
||||
'admin.mcpTokens.lastUsed': 'Zuletzt verwendet',
|
||||
'admin.mcpTokens.never': 'Nie',
|
||||
'admin.mcpTokens.empty': 'Es wurden noch keine MCP-Tokens erstellt',
|
||||
'admin.mcpTokens.deleteTitle': 'Token löschen',
|
||||
'admin.mcpTokens.deleteMessage': 'Dieser Token wird sofort widerrufen. Der Benutzer verliert den MCP-Zugang über diesen Token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token gelöscht',
|
||||
'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden',
|
||||
'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
@@ -505,7 +611,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Urlaubstage planen und verwalten',
|
||||
'vacay.settings': 'Einstellungen',
|
||||
'vacay.year': 'Jahr',
|
||||
'vacay.addYear': 'Jahr hinzufügen',
|
||||
'vacay.addYear': 'Nächstes Jahr hinzufügen',
|
||||
'vacay.addPrevYear': 'Vorheriges Jahr hinzufügen',
|
||||
'vacay.removeYear': 'Jahr entfernen',
|
||||
'vacay.removeYearConfirm': '{year} entfernen?',
|
||||
'vacay.removeYearHint': 'Alle Urlaubseinträge und Betriebsferien für dieses Jahr werden unwiderruflich gelöscht.',
|
||||
@@ -597,10 +704,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Entfernen',
|
||||
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
|
||||
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
|
||||
'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?',
|
||||
'atlas.markVisited': 'Als besucht markieren',
|
||||
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
||||
'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen',
|
||||
'atlas.addToBucket': 'Zur Bucket List',
|
||||
'atlas.addPoi': 'Ort hinzufügen',
|
||||
'atlas.searchCountry': 'Land suchen...',
|
||||
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
|
||||
'atlas.month': 'Monat',
|
||||
'atlas.year': 'Jahr',
|
||||
@@ -609,7 +719,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.statsTab': 'Statistik',
|
||||
'atlas.bucketTab': 'Bucket List',
|
||||
'atlas.addBucket': 'Zur Bucket List hinzufügen',
|
||||
'atlas.bucketNamePlaceholder': 'Ort oder Reiseziel...',
|
||||
'atlas.bucketNotesPlaceholder': 'Notizen (optional)',
|
||||
'atlas.bucketEmpty': 'Deine Bucket List ist leer',
|
||||
'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest',
|
||||
@@ -622,7 +731,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.nextTrip': 'Nächster Trip',
|
||||
'atlas.daysLeft': 'Tage',
|
||||
'atlas.streak': 'Streak',
|
||||
'atlas.year': 'Jahr',
|
||||
'atlas.years': 'Jahre',
|
||||
'atlas.yearInRow': 'Jahr in Folge',
|
||||
'atlas.yearsInRow': 'Jahre in Folge',
|
||||
@@ -649,9 +757,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.reservationsShort': 'Buchung',
|
||||
'trip.tabs.packing': 'Liste',
|
||||
'trip.tabs.packingShort': 'Liste',
|
||||
'trip.tabs.lists': 'Listen',
|
||||
'trip.tabs.listsShort': 'Listen',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Dateien',
|
||||
'trip.loading': 'Reise wird geladen...',
|
||||
'trip.loadingPhotos': 'Fotos der Orte werden geladen...',
|
||||
'trip.mobilePlan': 'Planung',
|
||||
'trip.mobilePlaces': 'Orte',
|
||||
'trip.toast.placeUpdated': 'Ort aktualisiert',
|
||||
@@ -698,10 +809,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'places.importGpx': 'GPX importieren',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||
'places.urlResolved': 'Ort aus URL importiert',
|
||||
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
||||
'places.importGoogleList': 'Google Liste',
|
||||
'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
|
||||
'places.googleListImported': '{count} Orte aus "{list}" importiert',
|
||||
'places.googleListError': 'Google Maps Liste konnte nicht importiert werden',
|
||||
'places.viewDetails': 'Details anzeigen',
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
@@ -757,6 +873,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'Reservierung',
|
||||
'inspector.editRes': 'Reservierung bearbeiten',
|
||||
'inspector.participants': 'Teilnehmer',
|
||||
'inspector.trackStats': 'Streckendaten',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Buchungen',
|
||||
@@ -836,9 +953,36 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
|
||||
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
|
||||
'reservations.noAssignment': 'Keine Verknüpfung',
|
||||
'reservations.price': 'Preis',
|
||||
'reservations.budgetCategory': 'Budgetkategorie',
|
||||
'reservations.budgetCategoryPlaceholder': 'z.B. Transport, Unterkunft',
|
||||
'reservations.budgetCategoryAuto': 'Auto (aus Buchungstyp)',
|
||||
'reservations.budgetHint': 'Beim Speichern wird automatisch ein Budgeteintrag erstellt.',
|
||||
'reservations.departureDate': 'Abflug',
|
||||
'reservations.arrivalDate': 'Ankunft',
|
||||
'reservations.departureTime': 'Abflugzeit',
|
||||
'reservations.arrivalTime': 'Ankunftszeit',
|
||||
'reservations.pickupDate': 'Abholung',
|
||||
'reservations.returnDate': 'Rückgabe',
|
||||
'reservations.pickupTime': 'Abholzeit',
|
||||
'reservations.returnTime': 'Rückgabezeit',
|
||||
'reservations.endDate': 'Enddatum',
|
||||
'reservations.meta.departureTimezone': 'Abfl. TZ',
|
||||
'reservations.meta.arrivalTimezone': 'Ank. TZ',
|
||||
'reservations.span.departure': 'Abflug',
|
||||
'reservations.span.arrival': 'Ankunft',
|
||||
'reservations.span.inTransit': 'Unterwegs',
|
||||
'reservations.span.pickup': 'Abholung',
|
||||
'reservations.span.return': 'Rückgabe',
|
||||
'reservations.span.active': 'Aktiv',
|
||||
'reservations.span.start': 'Start',
|
||||
'reservations.span.end': 'Ende',
|
||||
'reservations.span.ongoing': 'Laufend',
|
||||
'reservations.validation.endBeforeStart': 'Enddatum/-zeit muss nach dem Startdatum/-zeit liegen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.exportCsv': 'CSV exportieren',
|
||||
'budget.emptyTitle': 'Noch kein Budget erstellt',
|
||||
'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen',
|
||||
'budget.emptyPlaceholder': 'Kategoriename eingeben...',
|
||||
@@ -853,6 +997,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.table.perDay': 'Pro Tag',
|
||||
'budget.table.perPersonDay': 'P. p / Tag',
|
||||
'budget.table.note': 'Notiz',
|
||||
'budget.table.date': 'Datum',
|
||||
'budget.newEntry': 'Neuer Eintrag',
|
||||
'budget.defaultEntry': 'Neuer Eintrag',
|
||||
'budget.defaultCategory': 'Neue Kategorie',
|
||||
@@ -860,6 +1005,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.totalBudget': 'Gesamtbudget',
|
||||
'budget.byCategory': 'Nach Kategorie',
|
||||
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
||||
'budget.linkedToReservation': 'Verknüpft mit einer Buchung — Name dort bearbeiten',
|
||||
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
|
||||
'budget.deleteCategory': 'Kategorie löschen',
|
||||
'budget.perPerson': 'Pro Person',
|
||||
@@ -960,6 +1106,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'packing.template': 'Vorlage',
|
||||
'packing.templateApplied': '{count} Einträge aus Vorlage hinzugefügt',
|
||||
'packing.templateError': 'Vorlage konnte nicht angewendet werden',
|
||||
'packing.saveAsTemplate': 'Als Vorlage speichern',
|
||||
'packing.templateName': 'Vorlagenname',
|
||||
'packing.templateSaved': 'Packliste als Vorlage gespeichert',
|
||||
'packing.assignUser': 'Person zuweisen',
|
||||
'packing.bags': 'Gepäck',
|
||||
'packing.noBag': 'Nicht zugeordnet',
|
||||
'packing.totalWeight': 'Gesamtgewicht',
|
||||
@@ -1246,12 +1396,19 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichUrl': 'Immich Server URL',
|
||||
'memories.immichApiKey': 'API-Schlüssel',
|
||||
'memories.testConnection': 'Verbindung testen',
|
||||
'memories.testFirst': 'Verbindung zuerst testen',
|
||||
'memories.connected': 'Verbunden',
|
||||
'memories.disconnected': 'Nicht verbunden',
|
||||
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
|
||||
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
|
||||
'memories.saved': 'Immich-Einstellungen gespeichert',
|
||||
'memories.addPhotos': 'Fotos hinzufügen',
|
||||
'memories.linkAlbum': 'Album verknüpfen',
|
||||
'memories.selectAlbum': 'Immich-Album auswählen',
|
||||
'memories.noAlbums': 'Keine Alben gefunden',
|
||||
'memories.syncAlbum': 'Album synchronisieren',
|
||||
'memories.unlinkAlbum': 'Album trennen',
|
||||
'memories.photos': 'Fotos',
|
||||
'memories.selectPhotos': 'Fotos aus Immich auswählen',
|
||||
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
||||
'memories.selected': 'ausgewählt',
|
||||
@@ -1286,6 +1443,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.chat.today': 'Heute',
|
||||
'collab.chat.yesterday': 'Gestern',
|
||||
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
|
||||
'collab.chat.reply': 'Antworten',
|
||||
'collab.chat.loadMore': 'Ältere Nachrichten laden',
|
||||
'collab.chat.justNow': 'gerade eben',
|
||||
'collab.chat.minutesAgo': 'vor {n} Min.',
|
||||
@@ -1336,6 +1494,204 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.options': 'Optionen',
|
||||
'collab.polls.delete': 'Löschen',
|
||||
'collab.polls.closedSection': 'Geschlossen',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Berechtigungen',
|
||||
'perm.title': 'Berechtigungseinstellungen',
|
||||
'perm.subtitle': 'Steuern Sie, wer Aktionen in der Anwendung ausführen kann',
|
||||
'perm.saved': 'Berechtigungseinstellungen gespeichert',
|
||||
'perm.resetDefaults': 'Auf Standard zurücksetzen',
|
||||
'perm.customized': 'angepasst',
|
||||
'perm.level.admin': 'Nur Administrator',
|
||||
'perm.level.tripOwner': 'Reise-Eigentümer',
|
||||
'perm.level.tripMember': 'Reise-Mitglieder',
|
||||
'perm.level.everybody': 'Alle',
|
||||
'perm.cat.trip': 'Reiseverwaltung',
|
||||
'perm.cat.members': 'Mitgliederverwaltung',
|
||||
'perm.cat.files': 'Dateien',
|
||||
'perm.cat.content': 'Inhalte & Zeitplan',
|
||||
'perm.cat.extras': 'Budget, Packlisten & Zusammenarbeit',
|
||||
'perm.action.trip_create': 'Reisen erstellen',
|
||||
'perm.action.trip_edit': 'Reisedetails bearbeiten',
|
||||
'perm.action.trip_delete': 'Reisen löschen',
|
||||
'perm.action.trip_archive': 'Reisen archivieren / dearchivieren',
|
||||
'perm.action.trip_cover_upload': 'Titelbild hochladen',
|
||||
'perm.action.member_manage': 'Mitglieder hinzufügen / entfernen',
|
||||
'perm.action.file_upload': 'Dateien hochladen',
|
||||
'perm.action.file_edit': 'Datei-Metadaten bearbeiten',
|
||||
'perm.action.file_delete': 'Dateien löschen',
|
||||
'perm.action.place_edit': 'Orte hinzufügen / bearbeiten / löschen',
|
||||
'perm.action.day_edit': 'Tage, Notizen & Zuweisungen bearbeiten',
|
||||
'perm.action.reservation_edit': 'Reservierungen verwalten',
|
||||
'perm.action.budget_edit': 'Budget verwalten',
|
||||
'perm.action.packing_edit': 'Packlisten verwalten',
|
||||
'perm.action.collab_edit': 'Zusammenarbeit (Notizen, Umfragen, Chat)',
|
||||
'perm.action.share_manage': 'Freigabelinks verwalten',
|
||||
'perm.actionHint.trip_create': 'Wer kann neue Reisen erstellen',
|
||||
'perm.actionHint.trip_edit': 'Wer kann Reisename, Daten, Beschreibung und Währung ändern',
|
||||
'perm.actionHint.trip_delete': 'Wer kann eine Reise dauerhaft löschen',
|
||||
'perm.actionHint.trip_archive': 'Wer kann eine Reise archivieren oder dearchivieren',
|
||||
'perm.actionHint.trip_cover_upload': 'Wer kann das Titelbild hochladen oder ändern',
|
||||
'perm.actionHint.member_manage': 'Wer kann Reise-Mitglieder einladen oder entfernen',
|
||||
'perm.actionHint.file_upload': 'Wer kann Dateien zu einer Reise hochladen',
|
||||
'perm.actionHint.file_edit': 'Wer kann Dateibeschreibungen und Links bearbeiten',
|
||||
'perm.actionHint.file_delete': 'Wer kann Dateien in den Papierkorb verschieben oder dauerhaft löschen',
|
||||
'perm.actionHint.place_edit': 'Wer kann Orte hinzufügen, bearbeiten oder löschen',
|
||||
'perm.actionHint.day_edit': 'Wer kann Tage, Tagesnotizen und Ort-Zuweisungen bearbeiten',
|
||||
'perm.actionHint.reservation_edit': 'Wer kann Reservierungen erstellen, bearbeiten oder löschen',
|
||||
'perm.actionHint.budget_edit': 'Wer kann Budgetposten erstellen, bearbeiten oder löschen',
|
||||
'perm.actionHint.packing_edit': 'Wer kann Packstücke und Taschen verwalten',
|
||||
'perm.actionHint.collab_edit': 'Wer kann Notizen, Umfragen erstellen und Nachrichten senden',
|
||||
'perm.actionHint.share_manage': 'Wer kann öffentliche Freigabelinks erstellen oder löschen',
|
||||
// Undo
|
||||
'undo.button': 'Rückgängig',
|
||||
'undo.tooltip': 'Rückgängig: {action}',
|
||||
'undo.assignPlace': 'Ort einem Tag zugewiesen',
|
||||
'undo.removeAssignment': 'Ort von Tag entfernt',
|
||||
'undo.reorder': 'Orte neu sortiert',
|
||||
'undo.optimize': 'Route optimiert',
|
||||
'undo.deletePlace': 'Ort gelöscht',
|
||||
'undo.moveDay': 'Ort zu anderem Tag verschoben',
|
||||
'undo.lock': 'Ortssperre umgeschaltet',
|
||||
'undo.importGpx': 'GPX-Import',
|
||||
'undo.importGoogleList': 'Google Maps-Import',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Benachrichtigungen',
|
||||
'notifications.markAllRead': 'Alle als gelesen markieren',
|
||||
'notifications.deleteAll': 'Alle löschen',
|
||||
'notifications.showAll': 'Alle Benachrichtigungen anzeigen',
|
||||
'notifications.empty': 'Keine Benachrichtigungen',
|
||||
'notifications.emptyDescription': 'Sie sind auf dem neuesten Stand!',
|
||||
'notifications.all': 'Alle',
|
||||
'notifications.unreadOnly': 'Ungelesen',
|
||||
'notifications.markRead': 'Als gelesen markieren',
|
||||
'notifications.markUnread': 'Als ungelesen markieren',
|
||||
'notifications.delete': 'Löschen',
|
||||
'notifications.system': 'System',
|
||||
'memories.error.loadAlbums': 'Alben konnten nicht geladen werden',
|
||||
'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden',
|
||||
'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden',
|
||||
'memories.error.syncAlbum': 'Album konnte nicht synchronisiert werden',
|
||||
'memories.error.loadPhotos': 'Fotos konnten nicht geladen werden',
|
||||
'memories.error.addPhotos': 'Fotos konnten nicht hinzugefügt werden',
|
||||
'memories.error.removePhoto': 'Foto konnte nicht entfernt werden',
|
||||
'memories.error.toggleSharing': 'Freigabe konnte nicht aktualisiert werden',
|
||||
'undo.addPlace': 'Ort hinzugefügt',
|
||||
'undo.done': 'Rückgängig gemacht: {action}',
|
||||
'notifications.test.title': 'Testbenachrichtigung von {actor}',
|
||||
'notifications.test.text': 'Dies ist eine einfache Testbenachrichtigung.',
|
||||
'notifications.test.booleanTitle': '{actor} bittet um Ihre Zustimmung',
|
||||
'notifications.test.booleanText': 'Dies ist eine boolesche Testbenachrichtigung.',
|
||||
'notifications.test.accept': 'Genehmigen',
|
||||
'notifications.test.decline': 'Ablehnen',
|
||||
'notifications.test.navigateTitle': 'Etwas ansehen',
|
||||
'notifications.test.navigateText': 'Dies ist eine Navigations-Testbenachrichtigung.',
|
||||
'notifications.test.goThere': 'Dorthin',
|
||||
'notifications.test.adminTitle': 'Admin-Broadcast',
|
||||
'notifications.test.adminText': '{actor} hat eine Testbenachrichtigung an alle Admins gesendet.',
|
||||
'notifications.test.tripTitle': '{actor} hat in Ihrer Reise gepostet',
|
||||
'notifications.test.tripText': 'Testbenachrichtigung für Reise "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Packliste',
|
||||
'todo.subtab.todo': 'To-Do',
|
||||
'todo.completed': 'erledigt',
|
||||
'todo.filter.all': 'Alle',
|
||||
'todo.filter.open': 'Offen',
|
||||
'todo.filter.done': 'Erledigt',
|
||||
'todo.uncategorized': 'Ohne Kategorie',
|
||||
'todo.namePlaceholder': 'Aufgabenname',
|
||||
'todo.descriptionPlaceholder': 'Beschreibung (optional)',
|
||||
'todo.unassigned': 'Nicht zugewiesen',
|
||||
'todo.noCategory': 'Keine Kategorie',
|
||||
'todo.hasDescription': 'Hat Beschreibung',
|
||||
'todo.addItem': 'Neue Aufgabe hinzufügen...',
|
||||
'todo.newCategory': 'Kategoriename',
|
||||
'todo.addCategory': 'Kategorie hinzufügen',
|
||||
'todo.newItem': 'Neue Aufgabe',
|
||||
'todo.empty': 'Noch keine Aufgaben. Erstelle eine Aufgabe um loszulegen!',
|
||||
'todo.filter.my': 'Meine Aufgaben',
|
||||
'todo.filter.overdue': 'Überfällig',
|
||||
'todo.sidebar.tasks': 'Aufgaben',
|
||||
'todo.sidebar.categories': 'Kategorien',
|
||||
'todo.detail.title': 'Aufgabe',
|
||||
'todo.detail.description': 'Beschreibung',
|
||||
'todo.detail.category': 'Kategorie',
|
||||
'todo.detail.dueDate': 'Fällig am',
|
||||
'todo.detail.assignedTo': 'Zuständig',
|
||||
'todo.detail.delete': 'Löschen',
|
||||
'todo.detail.save': 'Speichern',
|
||||
'todo.sortByPrio': 'Priorität',
|
||||
'todo.detail.priority': 'Priorität',
|
||||
'todo.detail.noPriority': 'Keine',
|
||||
'todo.detail.create': 'Aufgabe erstellen',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'Neue Version verfügbar',
|
||||
'settings.notificationPreferences.noChannels': 'Keine Benachrichtigungskanäle konfiguriert. Bitte einen Administrator, E-Mail- oder Webhook-Benachrichtigungen einzurichten.',
|
||||
'settings.webhookUrl.label': 'Webhook URL',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Gib deine Discord-, Slack- oder benutzerdefinierte Webhook-URL ein, um Benachrichtigungen zu erhalten.',
|
||||
'settings.webhookUrl.save': 'Speichern',
|
||||
'settings.webhookUrl.saved': 'Webhook-URL gespeichert',
|
||||
'settings.webhookUrl.test': 'Testen',
|
||||
'settings.webhookUrl.testSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||
'settings.webhookUrl.testFailed': 'Test-Webhook fehlgeschlagen',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'In-App-Benachrichtigungen sind immer aktiv und können nicht global deaktiviert werden.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin-Webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Dieser Webhook wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Er ist unabhängig von den Benutzer-Webhooks und sendet automatisch, wenn eine URL konfiguriert ist.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'Admin-Webhook-URL gespeichert',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist',
|
||||
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
|
||||
'admin.tabs.notifications': 'Benachrichtigungen',
|
||||
'notifications.versionAvailable.title': 'Update verfügbar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
|
||||
'notifications.versionAvailable.button': 'Details anzeigen',
|
||||
'notif.test.title': '[Test] Benachrichtigung',
|
||||
'notif.test.simple.text': 'Dies ist eine einfache Testbenachrichtigung.',
|
||||
'notif.test.boolean.text': 'Akzeptierst du diese Testbenachrichtigung?',
|
||||
'notif.test.navigate.text': 'Klicke unten, um zum Dashboard zu navigieren.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Reiseeinladung',
|
||||
'notif.trip_invite.text': '{actor} hat dich zu {trip} eingeladen',
|
||||
'notif.booking_change.title': 'Buchung aktualisiert',
|
||||
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
|
||||
'notif.trip_reminder.title': 'Reiseerinnerung',
|
||||
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
|
||||
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
|
||||
'notif.photos_shared.title': 'Fotos geteilt',
|
||||
'notif.photos_shared.text': '{actor} hat {count} Foto(s) in {trip} geteilt',
|
||||
'notif.collab_message.title': 'Neue Nachricht',
|
||||
'notif.collab_message.text': '{actor} hat eine Nachricht in {trip} gesendet',
|
||||
'notif.packing_tagged.title': 'Packlistenzuweisung',
|
||||
'notif.packing_tagged.text': '{actor} hat dich zu {category} in {trip} zugewiesen',
|
||||
'notif.version_available.title': 'Neue Version verfügbar',
|
||||
'notif.version_available.text': 'TREK {version} ist jetzt verfügbar',
|
||||
'notif.action.view_trip': 'Reise ansehen',
|
||||
'notif.action.view_collab': 'Nachrichten ansehen',
|
||||
'notif.action.view_packing': 'Packliste ansehen',
|
||||
'notif.action.view_photos': 'Fotos ansehen',
|
||||
'notif.action.view_vacay': 'Vacay ansehen',
|
||||
'notif.action.view_admin': 'Zum Admin',
|
||||
'notif.action.view': 'Ansehen',
|
||||
'notif.action.accept': 'Annehmen',
|
||||
'notif.action.decline': 'Ablehnen',
|
||||
'notif.generic.title': 'Benachrichtigung',
|
||||
'notif.generic.text': 'Du hast eine neue Benachrichtigung',
|
||||
'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis',
|
||||
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.edit': 'Edit',
|
||||
'common.add': 'Add',
|
||||
'common.loading': 'Loading...',
|
||||
'common.import': 'Import',
|
||||
'common.error': 'Error',
|
||||
'common.back': 'Back',
|
||||
'common.all': 'All',
|
||||
@@ -25,6 +26,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Password',
|
||||
'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.change': 'Change',
|
||||
'common.uploading': 'Uploading…',
|
||||
@@ -71,7 +80,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.sharedBy': 'Shared by {name}',
|
||||
'dashboard.days': 'Days',
|
||||
'dashboard.places': 'Places',
|
||||
'dashboard.members': 'Buddies',
|
||||
'dashboard.archive': 'Archive',
|
||||
'dashboard.copyTrip': 'Copy',
|
||||
'dashboard.copySuffix': 'copy',
|
||||
'dashboard.restore': 'Restore',
|
||||
'dashboard.archived': 'Archived',
|
||||
'dashboard.status.ongoing': 'Ongoing',
|
||||
@@ -90,6 +102,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Failed to archive trip',
|
||||
'dashboard.toast.restored': 'Trip restored',
|
||||
'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.editTrip': 'Edit Trip',
|
||||
'dashboard.createTrip': 'Create New Trip',
|
||||
@@ -99,6 +113,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'What is this trip about?',
|
||||
'dashboard.startDate': 'Start Date',
|
||||
'dashboard.endDate': 'End Date',
|
||||
'dashboard.dayCount': 'Number of Days',
|
||||
'dashboard.dayCountHint': 'How many days to plan for when no travel dates are set.',
|
||||
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
|
||||
'dashboard.coverImage': 'Cover Image',
|
||||
'dashboard.addCoverImage': 'Add cover image (or drag & drop)',
|
||||
@@ -113,6 +129,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Settings
|
||||
'settings.title': 'Settings',
|
||||
'settings.subtitle': 'Configure your personal settings',
|
||||
'settings.tabs.display': 'Display',
|
||||
'settings.tabs.map': 'Map',
|
||||
'settings.tabs.notifications': 'Notifications',
|
||||
'settings.tabs.integrations': 'Integrations',
|
||||
'settings.tabs.account': 'Account',
|
||||
'settings.tabs.about': 'About',
|
||||
'settings.map': 'Map',
|
||||
'settings.mapTemplate': 'Map Template',
|
||||
'settings.mapTemplatePlaceholder.select': 'Select template...',
|
||||
@@ -149,11 +171,49 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||
'settings.notifyWebhook': 'Webhook notifications',
|
||||
'settings.notifyVersionAvailable': 'New version available',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
|
||||
'settings.webhookUrl.label': 'Webhook URL',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Enter your Discord, Slack, or custom webhook URL to receive notifications.',
|
||||
'settings.webhookUrl.save': 'Save',
|
||||
'settings.webhookUrl.saved': 'Webhook URL saved',
|
||||
'settings.webhookUrl.test': 'Test',
|
||||
'settings.webhookUrl.testSuccess': 'Test webhook sent successfully',
|
||||
'settings.webhookUrl.testFailed': 'Test webhook failed',
|
||||
'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.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.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin Webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'This webhook is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user webhooks and always fires when set.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL saved',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
||||
'admin.smtp.title': 'Email & Notifications',
|
||||
'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.',
|
||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||
'admin.smtp.testButton': 'Send test email',
|
||||
'admin.webhook.hint': 'Allow users to configure their own webhook URLs for notifications (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.',
|
||||
@@ -185,7 +245,41 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'On',
|
||||
'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.about': 'About',
|
||||
'settings.about.reportBug': 'Report a Bug',
|
||||
'settings.about.reportBugHint': 'Found an issue? Let us know',
|
||||
'settings.about.featureRequest': 'Feature Request',
|
||||
'settings.about.featureRequestHint': 'Suggest a new feature',
|
||||
'settings.about.wikiHint': 'Documentation & guides',
|
||||
'settings.about.description': 'TREK is a self-hosted travel planner that helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place, on your own server.',
|
||||
'settings.about.madeWith': 'Made with',
|
||||
'settings.about.madeBy': 'by Maurice and a growing open-source community.',
|
||||
'settings.username': 'Username',
|
||||
'settings.email': 'Email',
|
||||
'settings.role': 'Role',
|
||||
@@ -200,8 +294,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.passwordRequired': 'Please enter current and new password',
|
||||
'settings.passwordTooShort': 'Password must be at least 8 characters',
|
||||
'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.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.',
|
||||
'settings.deleteAccount': 'Delete 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.',
|
||||
@@ -221,6 +316,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
@@ -263,6 +366,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.signIn': 'Sign In',
|
||||
'login.createAdmin': 'Create Admin Account',
|
||||
'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.createAccountHint': 'Register a new account.',
|
||||
'login.creating': 'Creating…',
|
||||
@@ -289,7 +394,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Register
|
||||
'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.getStarted': 'Get Started',
|
||||
'register.subtitle': 'Create an account and start planning your dream trips.',
|
||||
@@ -315,7 +420,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'Users',
|
||||
'admin.tabs.categories': 'Categories',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.tabs.audit': 'Audit log',
|
||||
'admin.tabs.notifications': 'Notifications',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
'admin.stats.users': 'Users',
|
||||
'admin.stats.trips': 'Trips',
|
||||
'admin.stats.places': 'Places',
|
||||
@@ -365,6 +471,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.settings': 'Settings',
|
||||
'admin.allowRegistration': 'Allow Registration',
|
||||
'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.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
||||
'admin.mapsKey': 'Google Maps API Key',
|
||||
@@ -395,7 +503,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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.config': 'Personalization',
|
||||
'admin.tabs.templates': 'Packing Templates',
|
||||
'admin.packingTemplates.title': 'Packing Templates',
|
||||
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
|
||||
@@ -419,8 +527,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
'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.packing.name': 'Lists',
|
||||
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
|
||||
'admin.addons.catalog.budget.name': 'Budget',
|
||||
'admin.addons.catalog.budget.description': 'Track expenses and plan your trip budget',
|
||||
'admin.addons.catalog.documents.name': 'Documents',
|
||||
@@ -433,14 +541,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.subtitleAfter': ' experience.',
|
||||
'admin.addons.enabled': 'Enabled',
|
||||
'admin.addons.disabled': 'Disabled',
|
||||
'admin.addons.type.trip': 'Trip',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.integration': 'Integration',
|
||||
'admin.addons.tripHint': 'Available as a tab within each trip',
|
||||
'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.error': 'Failed to update addon',
|
||||
'admin.addons.noAddons': 'No addons available',
|
||||
@@ -457,6 +569,20 @@ const en: Record<string, string | { name: string; category: 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.',
|
||||
|
||||
// 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.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
|
||||
@@ -504,7 +630,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Plan and manage vacation days',
|
||||
'vacay.settings': 'Settings',
|
||||
'vacay.year': 'Year',
|
||||
'vacay.addYear': 'Add year',
|
||||
'vacay.addYear': 'Add next year',
|
||||
'vacay.addPrevYear': 'Add previous year',
|
||||
'vacay.removeYear': 'Remove year',
|
||||
'vacay.removeYearConfirm': 'Remove {year}?',
|
||||
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
|
||||
@@ -596,10 +723,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Remove',
|
||||
'atlas.confirmMark': 'Mark this country as visited?',
|
||||
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
||||
'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?',
|
||||
'atlas.markVisited': 'Mark as visited',
|
||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||
'atlas.markRegionVisitedHint': 'Add this region 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',
|
||||
@@ -608,7 +738,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.statsTab': 'Stats',
|
||||
'atlas.bucketTab': 'Bucket List',
|
||||
'atlas.addBucket': 'Add to bucket list',
|
||||
'atlas.bucketNamePlaceholder': 'Place or destination...',
|
||||
'atlas.bucketNotesPlaceholder': 'Notes (optional)',
|
||||
'atlas.bucketEmpty': 'Your bucket list is empty',
|
||||
'atlas.bucketEmptyHint': 'Add places you dream of visiting',
|
||||
@@ -621,7 +750,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.nextTrip': 'Next trip',
|
||||
'atlas.daysLeft': 'days left',
|
||||
'atlas.streak': 'Streak',
|
||||
'atlas.year': 'year',
|
||||
'atlas.years': 'years',
|
||||
'atlas.yearInRow': 'year in a row',
|
||||
'atlas.yearsInRow': 'years in a row',
|
||||
@@ -648,9 +776,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.reservationsShort': 'Book',
|
||||
'trip.tabs.packing': 'Packing List',
|
||||
'trip.tabs.packingShort': 'Packing',
|
||||
'trip.tabs.lists': 'Lists',
|
||||
'trip.tabs.listsShort': 'Lists',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Files',
|
||||
'trip.loading': 'Loading trip...',
|
||||
'trip.loadingPhotos': 'Loading place photos...',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Places',
|
||||
'trip.toast.placeUpdated': 'Place updated',
|
||||
@@ -697,10 +828,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place/Activity',
|
||||
'places.importGpx': 'Import GPX',
|
||||
'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.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
@@ -756,6 +892,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'Reservation',
|
||||
'inspector.editRes': 'Edit Reservation',
|
||||
'inspector.participants': 'Participants',
|
||||
'inspector.trackStats': 'Track Stats',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Bookings',
|
||||
@@ -835,9 +972,36 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.linkAssignment': 'Link to day assignment',
|
||||
'reservations.pickAssignment': 'Select an assignment from your plan...',
|
||||
'reservations.noAssignment': 'No link (standalone)',
|
||||
'reservations.price': 'Price',
|
||||
'reservations.budgetCategory': 'Budget category',
|
||||
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
|
||||
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
|
||||
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
|
||||
'reservations.departureDate': 'Departure',
|
||||
'reservations.arrivalDate': 'Arrival',
|
||||
'reservations.departureTime': 'Dep. time',
|
||||
'reservations.arrivalTime': 'Arr. time',
|
||||
'reservations.pickupDate': 'Pickup',
|
||||
'reservations.returnDate': 'Return',
|
||||
'reservations.pickupTime': 'Pickup time',
|
||||
'reservations.returnTime': 'Return time',
|
||||
'reservations.endDate': 'End date',
|
||||
'reservations.meta.departureTimezone': 'Dep. TZ',
|
||||
'reservations.meta.arrivalTimezone': 'Arr. TZ',
|
||||
'reservations.span.departure': 'Departure',
|
||||
'reservations.span.arrival': 'Arrival',
|
||||
'reservations.span.inTransit': 'In transit',
|
||||
'reservations.span.pickup': 'Pickup',
|
||||
'reservations.span.return': 'Return',
|
||||
'reservations.span.active': 'Active',
|
||||
'reservations.span.start': 'Start',
|
||||
'reservations.span.end': 'End',
|
||||
'reservations.span.ongoing': 'Ongoing',
|
||||
'reservations.validation.endBeforeStart': 'End date/time must be after start date/time',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.exportCsv': 'Export CSV',
|
||||
'budget.emptyTitle': 'No budget created yet',
|
||||
'budget.emptyText': 'Create categories and entries to plan your travel budget',
|
||||
'budget.emptyPlaceholder': 'Enter category name...',
|
||||
@@ -852,6 +1016,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.table.perDay': 'Per Day',
|
||||
'budget.table.perPersonDay': 'P. p / Day',
|
||||
'budget.table.note': 'Note',
|
||||
'budget.table.date': 'Date',
|
||||
'budget.newEntry': 'New Entry',
|
||||
'budget.defaultEntry': 'New Entry',
|
||||
'budget.defaultCategory': 'New Category',
|
||||
@@ -859,6 +1024,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.totalBudget': 'Total Budget',
|
||||
'budget.byCategory': 'By Category',
|
||||
'budget.editTooltip': 'Click to edit',
|
||||
'budget.linkedToReservation': 'Linked to a reservation — edit the name there',
|
||||
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
|
||||
'budget.deleteCategory': 'Delete Category',
|
||||
'budget.perPerson': 'Per Person',
|
||||
@@ -959,6 +1125,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'packing.template': 'Template',
|
||||
'packing.templateApplied': '{count} items added from template',
|
||||
'packing.templateError': 'Failed to apply template',
|
||||
'packing.saveAsTemplate': 'Save as template',
|
||||
'packing.templateName': 'Template name',
|
||||
'packing.templateSaved': 'Packing list saved as template',
|
||||
'packing.assignUser': 'Assign user',
|
||||
'packing.bags': 'Bags',
|
||||
'packing.noBag': 'Unassigned',
|
||||
'packing.totalWeight': 'Total weight',
|
||||
@@ -1230,11 +1400,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// 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.notConnected': '{provider_name} not connected',
|
||||
'memories.notConnectedHint': 'Connect your {provider_name} instance in Settings to be able add photos to this trip.',
|
||||
'memories.notConnectedMultipleHint': 'Connect any of these photo providers: {provider_names} in Settings to be able add photos to this trip.',
|
||||
'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.noPhotosHint': 'No photos found in {provider_name} for this trip\'s date range.',
|
||||
'memories.photosFound': 'photos',
|
||||
'memories.fromOthers': 'from others',
|
||||
'memories.sharePhotos': 'Share photos',
|
||||
@@ -1242,16 +1413,31 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
//-------------------------
|
||||
//todo section
|
||||
'memories.providerUrl': 'Server URL',
|
||||
'memories.providerApiKey': 'API Key',
|
||||
'memories.providerUsername': 'Username',
|
||||
'memories.providerPassword': 'Password',
|
||||
'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.connectionSuccess': 'Connected to {provider_name}',
|
||||
'memories.connectionError': 'Could not connect to {provider_name}',
|
||||
'memories.saved': '{provider_name} settings saved',
|
||||
'memories.saveError': 'Could not save {provider_name} settings',
|
||||
//------------------------
|
||||
'memories.addPhotos': 'Add photos',
|
||||
'memories.selectPhotos': 'Select photos from Immich',
|
||||
'memories.linkAlbum': 'Link Album',
|
||||
'memories.selectAlbum': 'Select {provider_name} Album',
|
||||
'memories.selectAlbumMultiple': 'Select Album',
|
||||
'memories.noAlbums': 'No albums found',
|
||||
'memories.syncAlbum': 'Sync album',
|
||||
'memories.unlinkAlbum': 'Unlink album',
|
||||
'memories.photos': 'photos',
|
||||
'memories.selectPhotos': 'Select photos from {provider_name}',
|
||||
'memories.selectPhotosMultiple': 'Select Photos',
|
||||
'memories.selectHint': 'Tap photos to select them.',
|
||||
'memories.selected': 'selected',
|
||||
'memories.addSelected': 'Add {count} photos',
|
||||
@@ -1266,6 +1452,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.tabs.chat': 'Chat',
|
||||
@@ -1285,6 +1479,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.chat.today': 'Today',
|
||||
'collab.chat.yesterday': 'Yesterday',
|
||||
'collab.chat.deletedMessage': 'deleted a message',
|
||||
'collab.chat.reply': 'Reply',
|
||||
'collab.chat.loadMore': 'Load older messages',
|
||||
'collab.chat.justNow': 'just now',
|
||||
'collab.chat.minutesAgo': '{n}m ago',
|
||||
@@ -1335,6 +1530,173 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.options': 'Options',
|
||||
'collab.polls.delete': 'Delete',
|
||||
'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.versionAvailable.title': 'Update Available',
|
||||
'notifications.versionAvailable.text': 'TREK {version} is now available.',
|
||||
'notifications.versionAvailable.button': 'View Details',
|
||||
'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}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Packing List',
|
||||
'todo.subtab.todo': 'To-Do',
|
||||
'todo.completed': 'completed',
|
||||
'todo.filter.all': 'All',
|
||||
'todo.filter.open': 'Open',
|
||||
'todo.filter.done': 'Done',
|
||||
'todo.uncategorized': 'Uncategorized',
|
||||
'todo.namePlaceholder': 'Task name',
|
||||
'todo.descriptionPlaceholder': 'Description (optional)',
|
||||
'todo.unassigned': 'Unassigned',
|
||||
'todo.noCategory': 'No category',
|
||||
'todo.hasDescription': 'Has description',
|
||||
'todo.addItem': 'Add new task...',
|
||||
'todo.newCategory': 'Category name',
|
||||
'todo.addCategory': 'Add category',
|
||||
'todo.newItem': 'New task',
|
||||
'todo.empty': 'No tasks yet. Add a task to get started!',
|
||||
'todo.filter.my': 'My Tasks',
|
||||
'todo.filter.overdue': 'Overdue',
|
||||
'todo.sidebar.tasks': 'Tasks',
|
||||
'todo.sidebar.categories': 'Categories',
|
||||
'todo.detail.title': 'Task',
|
||||
'todo.detail.description': 'Description',
|
||||
'todo.detail.category': 'Category',
|
||||
'todo.detail.dueDate': 'Due date',
|
||||
'todo.detail.assignedTo': 'Assigned to',
|
||||
'todo.detail.delete': 'Delete',
|
||||
'todo.detail.save': 'Save changes',
|
||||
'todo.sortByPrio': 'Priority',
|
||||
'todo.detail.priority': 'Priority',
|
||||
'todo.detail.noPriority': 'None',
|
||||
'todo.detail.create': 'Create task',
|
||||
|
||||
// Notifications — dev test events
|
||||
'notif.test.title': '[Test] Notification',
|
||||
'notif.test.simple.text': 'This is a simple test notification.',
|
||||
'notif.test.boolean.text': 'Do you accept this test notification?',
|
||||
'notif.test.navigate.text': 'Click below to navigate to the dashboard.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Trip Invitation',
|
||||
'notif.trip_invite.text': '{actor} invited you to {trip}',
|
||||
'notif.booking_change.title': 'Booking Updated',
|
||||
'notif.booking_change.text': '{actor} updated a booking in {trip}',
|
||||
'notif.trip_reminder.title': 'Trip Reminder',
|
||||
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion Invite',
|
||||
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
|
||||
'notif.photos_shared.title': 'Photos Shared',
|
||||
'notif.photos_shared.text': '{actor} shared {count} photo(s) in {trip}',
|
||||
'notif.collab_message.title': 'New Message',
|
||||
'notif.collab_message.text': '{actor} sent a message in {trip}',
|
||||
'notif.packing_tagged.title': 'Packing Assignment',
|
||||
'notif.packing_tagged.text': '{actor} assigned you to {category} in {trip}',
|
||||
'notif.version_available.title': 'New Version Available',
|
||||
'notif.version_available.text': 'TREK {version} is now available',
|
||||
'notif.action.view_trip': 'View Trip',
|
||||
'notif.action.view_collab': 'View Messages',
|
||||
'notif.action.view_packing': 'View Packing',
|
||||
'notif.action.view_photos': 'View Photos',
|
||||
'notif.action.view_vacay': 'View Vacay',
|
||||
'notif.action.view_admin': 'Go to Admin',
|
||||
'notif.action.view': 'View',
|
||||
'notif.action.accept': 'Accept',
|
||||
'notif.action.decline': 'Decline',
|
||||
'notif.generic.title': 'Notification',
|
||||
'notif.generic.text': 'You have a new notification',
|
||||
'notif.dev.unknown_event.title': '[DEV] Unknown Event',
|
||||
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
@@ -6,6 +6,7 @@ const es: Record<string, string> = {
|
||||
'common.edit': 'Editar',
|
||||
'common.add': 'Añadir',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.import': 'Importar',
|
||||
'common.error': 'Error',
|
||||
'common.back': 'Atrás',
|
||||
'common.all': 'Todo',
|
||||
@@ -25,6 +26,14 @@ const es: Record<string, string> = {
|
||||
'common.email': 'Correo',
|
||||
'common.password': 'Contraseña',
|
||||
'common.saving': 'Guardando...',
|
||||
'common.saved': 'Guardado',
|
||||
'trips.reminder': 'Recordatorio',
|
||||
'trips.reminderNone': 'Ninguno',
|
||||
'trips.reminderDay': 'día',
|
||||
'trips.reminderDays': 'días',
|
||||
'trips.reminderCustom': 'Personalizado',
|
||||
'trips.reminderDaysBefore': 'días antes de la salida',
|
||||
'trips.reminderDisabledHint': 'Los recordatorios de viaje están desactivados. Actívalos en Admin > Configuración > Notificaciones.',
|
||||
'common.update': 'Actualizar',
|
||||
'common.change': 'Cambiar',
|
||||
'common.uploading': 'Subiendo…',
|
||||
@@ -72,7 +81,10 @@ const es: Record<string, string> = {
|
||||
'dashboard.sharedBy': 'Compartido por {name}',
|
||||
'dashboard.days': 'Días',
|
||||
'dashboard.places': 'Lugares',
|
||||
'dashboard.members': 'Compañeros de viaje',
|
||||
'dashboard.archive': 'Archivar',
|
||||
'dashboard.copyTrip': 'Copiar',
|
||||
'dashboard.copySuffix': 'copia',
|
||||
'dashboard.restore': 'Restaurar',
|
||||
'dashboard.archived': 'Archivado',
|
||||
'dashboard.status.ongoing': 'En curso',
|
||||
@@ -91,6 +103,8 @@ const es: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': 'No se pudo archivar el viaje',
|
||||
'dashboard.toast.restored': 'Viaje restaurado',
|
||||
'dashboard.toast.restoreError': 'No se pudo restaurar el viaje',
|
||||
'dashboard.toast.copied': '¡Viaje copiado!',
|
||||
'dashboard.toast.copyError': 'No se pudo copiar el viaje',
|
||||
'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.',
|
||||
'dashboard.editTrip': 'Editar viaje',
|
||||
'dashboard.createTrip': 'Crear nuevo viaje',
|
||||
@@ -100,6 +114,8 @@ const es: Record<string, string> = {
|
||||
'dashboard.tripDescriptionPlaceholder': '¿De qué trata este viaje?',
|
||||
'dashboard.startDate': 'Fecha de inicio',
|
||||
'dashboard.endDate': 'Fecha de fin',
|
||||
'dashboard.dayCount': 'Número de días',
|
||||
'dashboard.dayCountHint': 'Cuántos días planificar cuando no se han establecido fechas de viaje.',
|
||||
'dashboard.noDateHint': 'Sin fecha definida: se crearán 7 días por defecto. Puedes cambiarlo cuando quieras.',
|
||||
'dashboard.coverImage': 'Imagen de portada',
|
||||
'dashboard.addCoverImage': 'Añadir imagen de portada',
|
||||
@@ -114,6 +130,12 @@ const es: Record<string, string> = {
|
||||
// Settings
|
||||
'settings.title': 'Ajustes',
|
||||
'settings.subtitle': 'Configura tus ajustes personales',
|
||||
'settings.tabs.display': 'Pantalla',
|
||||
'settings.tabs.map': 'Mapa',
|
||||
'settings.tabs.notifications': 'Notificaciones',
|
||||
'settings.tabs.integrations': 'Integraciones',
|
||||
'settings.tabs.account': 'Cuenta',
|
||||
'settings.tabs.about': 'Acerca de',
|
||||
'settings.map': 'Mapa',
|
||||
'settings.mapTemplate': 'Plantilla del mapa',
|
||||
'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...',
|
||||
@@ -150,9 +172,26 @@ const es: Record<string, string> = {
|
||||
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
|
||||
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
|
||||
'settings.notifyWebhook': 'Notificaciones webhook',
|
||||
'settings.notificationsDisabled': 'Las notificaciones no están configuradas. Pida a un administrador que active las notificaciones por correo o webhook.',
|
||||
'settings.notificationsActive': 'Canal activo',
|
||||
'settings.notificationsManagedByAdmin': 'Los eventos de notificación son configurados por el administrador.',
|
||||
'admin.notifications.title': 'Notificaciones',
|
||||
'admin.notifications.hint': 'Elija un canal de notificación. Solo uno puede estar activo a la vez.',
|
||||
'admin.notifications.none': 'Desactivado',
|
||||
'admin.notifications.email': 'Correo (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Eventos de notificación',
|
||||
'admin.notifications.eventsHint': 'Elige qué eventos activan notificaciones para todos los usuarios.',
|
||||
'admin.notifications.configureFirst': 'Configura primero los ajustes SMTP o webhook a continuación, luego activa los eventos.',
|
||||
'admin.notifications.save': 'Guardar configuración de notificaciones',
|
||||
'admin.notifications.saved': 'Configuración de notificaciones guardada',
|
||||
'admin.notifications.testWebhook': 'Enviar webhook de prueba',
|
||||
'admin.notifications.testWebhookSuccess': 'Webhook de prueba enviado correctamente',
|
||||
'admin.notifications.testWebhookFailed': 'Error al enviar webhook de prueba',
|
||||
'admin.smtp.title': 'Correo y notificaciones',
|
||||
'admin.smtp.hint': 'Configuración SMTP para notificaciones por correo. Opcional: URL webhook para Discord, Slack, etc.',
|
||||
'admin.smtp.hint': 'Configuración SMTP para el envío de notificaciones por correo.',
|
||||
'admin.smtp.testButton': 'Enviar correo de prueba',
|
||||
'admin.webhook.hint': 'Enviar notificaciones a un webhook externo (Discord, Slack, etc.).',
|
||||
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
|
||||
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
|
||||
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
|
||||
@@ -186,13 +225,48 @@ const es: Record<string, string> = {
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'Activado',
|
||||
'settings.off': 'Desactivado',
|
||||
'settings.mcp.title': 'Configuración MCP',
|
||||
'settings.mcp.endpoint': 'Endpoint MCP',
|
||||
'settings.mcp.clientConfig': 'Configuración del cliente',
|
||||
'settings.mcp.clientConfigHint': 'Reemplaza <your_token> con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
|
||||
'settings.mcp.copy': 'Copiar',
|
||||
'settings.mcp.copied': '¡Copiado!',
|
||||
'settings.mcp.apiTokens': 'Tokens de API',
|
||||
'settings.mcp.createToken': 'Crear nuevo token',
|
||||
'settings.mcp.noTokens': 'Sin tokens aún. Crea uno para conectar clientes MCP.',
|
||||
'settings.mcp.tokenCreatedAt': 'Creado',
|
||||
'settings.mcp.tokenUsedAt': 'Usado',
|
||||
'settings.mcp.deleteTokenTitle': 'Eliminar token',
|
||||
'settings.mcp.deleteTokenMessage': 'Este token dejará de funcionar de inmediato. Cualquier cliente MCP que lo use perderá el acceso.',
|
||||
'settings.mcp.modal.createTitle': 'Crear token de API',
|
||||
'settings.mcp.modal.tokenName': 'Nombre del token',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'p. ej. Claude Desktop, Portátil de trabajo',
|
||||
'settings.mcp.modal.creating': 'Creando…',
|
||||
'settings.mcp.modal.create': 'Crear token',
|
||||
'settings.mcp.modal.createdTitle': 'Token creado',
|
||||
'settings.mcp.modal.createdWarning': 'Este token solo se mostrará una vez. Cópialo y guárdalo ahora — no se podrá recuperar.',
|
||||
'settings.mcp.modal.done': 'Listo',
|
||||
'settings.mcp.toast.created': 'Token creado',
|
||||
'settings.mcp.toast.createError': 'Error al crear el token',
|
||||
'settings.mcp.toast.deleted': 'Token eliminado',
|
||||
'settings.mcp.toast.deleteError': 'Error al eliminar el token',
|
||||
'settings.account': 'Cuenta',
|
||||
'settings.about': 'Acerca de',
|
||||
'settings.about.reportBug': 'Reportar un error',
|
||||
'settings.about.reportBugHint': 'Encontraste un problema? Avísanos',
|
||||
'settings.about.featureRequest': 'Solicitar función',
|
||||
'settings.about.featureRequestHint': 'Sugiere una nueva función',
|
||||
'settings.about.wikiHint': 'Documentación y guías',
|
||||
'settings.about.description': 'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
|
||||
'settings.about.madeWith': 'Hecho con',
|
||||
'settings.about.madeBy': 'por Maurice y una creciente comunidad de código abierto.',
|
||||
'settings.username': 'Usuario',
|
||||
'settings.email': 'Correo',
|
||||
'settings.role': 'Rol',
|
||||
'settings.roleAdmin': 'Administrador',
|
||||
'settings.oidcLinked': 'Vinculado con',
|
||||
'settings.changePassword': 'Cambiar contraseña',
|
||||
'settings.mustChangePassword': 'Debe cambiar su contraseña antes de continuar. Establezca una nueva contraseña a continuación.',
|
||||
'settings.currentPassword': 'Contraseña actual',
|
||||
'settings.newPassword': 'Nueva contraseña',
|
||||
'settings.confirmPassword': 'Confirmar nueva contraseña',
|
||||
@@ -211,6 +285,14 @@ const es: Record<string, string> = {
|
||||
'settings.saveProfile': 'Guardar perfil',
|
||||
'settings.mfa.title': 'Autenticación de dos factores (2FA)',
|
||||
'settings.mfa.description': 'Añade un segundo paso al iniciar sesión. Usa una app de autenticación (Google Authenticator, Authy, etc.).',
|
||||
'settings.mfa.requiredByPolicy': 'Tu administrador exige autenticación en dos factores. Configura una app de autenticación abajo antes de continuar.',
|
||||
'settings.mfa.backupTitle': 'Códigos de respaldo',
|
||||
'settings.mfa.backupDescription': 'Usa estos códigos de un solo uso si pierdes acceso a tu app autenticadora.',
|
||||
'settings.mfa.backupWarning': 'Guárdalos ahora. Cada código solo se puede usar una vez.',
|
||||
'settings.mfa.backupCopy': 'Copiar códigos',
|
||||
'settings.mfa.backupDownload': 'Descargar TXT',
|
||||
'settings.mfa.backupPrint': 'Imprimir / PDF',
|
||||
'settings.mfa.backupCopied': 'Códigos de respaldo copiados',
|
||||
'settings.mfa.enabled': '2FA está activado en tu cuenta.',
|
||||
'settings.mfa.disabled': '2FA no está activado.',
|
||||
'settings.mfa.setup': 'Configurar autenticador',
|
||||
@@ -261,7 +343,9 @@ const es: Record<string, string> = {
|
||||
'login.signingIn': 'Iniciando sesión…',
|
||||
'login.signIn': 'Entrar',
|
||||
'login.createAdmin': 'Crear cuenta de administrador',
|
||||
'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.',
|
||||
'login.createAdminHint': 'Configura la primera cuenta administradora de TREK.',
|
||||
'login.setNewPassword': 'Establecer nueva contraseña',
|
||||
'login.setNewPasswordHint': 'Debe cambiar su contraseña antes de continuar.',
|
||||
'login.createAccount': 'Crear cuenta',
|
||||
'login.createAccountHint': 'Crea una cuenta nueva.',
|
||||
'login.creating': 'Creando…',
|
||||
@@ -287,7 +371,7 @@ const es: Record<string, string> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Las contraseñas no coinciden',
|
||||
'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres',
|
||||
'register.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres',
|
||||
'register.failed': 'Falló el registro',
|
||||
'register.getStarted': 'Empezar',
|
||||
'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.',
|
||||
@@ -313,7 +397,7 @@ const es: Record<string, string> = {
|
||||
'admin.tabs.users': 'Usuarios',
|
||||
'admin.tabs.categories': 'Categorías',
|
||||
'admin.tabs.backup': 'Copia de seguridad',
|
||||
'admin.tabs.audit': 'Registro de auditoría',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
'admin.stats.users': 'Usuarios',
|
||||
'admin.stats.trips': 'Viajes',
|
||||
'admin.stats.places': 'Lugares',
|
||||
@@ -363,6 +447,8 @@ const es: Record<string, string> = {
|
||||
'admin.tabs.settings': 'Ajustes',
|
||||
'admin.allowRegistration': 'Permitir el registro',
|
||||
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
|
||||
'admin.requireMfa': 'Exigir autenticación en dos factores (2FA)',
|
||||
'admin.requireMfaHint': 'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.',
|
||||
'admin.apiKeys': 'Claves API',
|
||||
'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.',
|
||||
'admin.mapsKey': 'Clave API de Google Maps',
|
||||
@@ -390,7 +476,7 @@ const es: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': 'Seguimiento de equipaje',
|
||||
'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista',
|
||||
'admin.tabs.config': 'Configuración',
|
||||
'admin.tabs.config': 'Personalización',
|
||||
'admin.tabs.templates': 'Plantillas de equipaje',
|
||||
'admin.packingTemplates.title': 'Plantillas de equipaje',
|
||||
'admin.packingTemplates.subtitle': 'Crear listas de equipaje reutilizables para tus viajes',
|
||||
@@ -413,21 +499,23 @@ const es: Record<string, string> = {
|
||||
// Addons
|
||||
'admin.tabs.addons': 'Complementos',
|
||||
'admin.addons.title': 'Complementos',
|
||||
'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en NOMAD.',
|
||||
'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en TREK.',
|
||||
'admin.addons.subtitleBefore': 'Activa o desactiva funciones para personalizar tu experiencia en ',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
'admin.addons.enabled': 'Activo',
|
||||
'admin.addons.disabled': 'Desactivado',
|
||||
'admin.addons.type.trip': 'Viaje',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.integration': 'Integración',
|
||||
'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje',
|
||||
'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal',
|
||||
'admin.addons.integrationHint': 'Servicios backend e integraciones de API sin página dedicada',
|
||||
'admin.addons.toast.updated': 'Complemento actualizado',
|
||||
'admin.addons.toast.error': 'No se pudo actualizar el complemento',
|
||||
'admin.addons.noAddons': 'No hay complementos disponibles',
|
||||
'admin.weather.title': 'Datos meteorológicos',
|
||||
'admin.weather.badge': 'Desde el 24 de marzo de 2026',
|
||||
'admin.weather.description': 'NOMAD utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.',
|
||||
'admin.weather.description': 'TREK utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.',
|
||||
'admin.weather.forecast': 'Pronóstico de 16 días',
|
||||
'admin.weather.forecastDesc': 'Antes eran 5 días (OpenWeatherMap)',
|
||||
'admin.weather.climate': 'Datos climáticos históricos',
|
||||
@@ -436,6 +524,22 @@ const es: Record<string, string> = {
|
||||
'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API',
|
||||
'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'Tokens MCP',
|
||||
'admin.mcpTokens.title': 'Tokens MCP',
|
||||
'admin.mcpTokens.subtitle': 'Gestionar tokens de API de todos los usuarios',
|
||||
'admin.mcpTokens.owner': 'Propietario',
|
||||
'admin.mcpTokens.tokenName': 'Nombre del token',
|
||||
'admin.mcpTokens.created': 'Creado',
|
||||
'admin.mcpTokens.lastUsed': 'Último uso',
|
||||
'admin.mcpTokens.never': 'Nunca',
|
||||
'admin.mcpTokens.empty': 'Aún no se han creado tokens MCP',
|
||||
'admin.mcpTokens.deleteTitle': 'Eliminar token',
|
||||
'admin.mcpTokens.deleteMessage': 'Este token se revocará inmediatamente. El usuario perderá el acceso MCP a través de este token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token eliminado',
|
||||
'admin.mcpTokens.deleteError': 'No se pudo eliminar el token',
|
||||
'admin.mcpTokens.loadError': 'No se pudieron cargar los tokens',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
@@ -463,11 +567,11 @@ const es: Record<string, string> = {
|
||||
'admin.github.error': 'No se pudieron cargar las versiones',
|
||||
'admin.github.by': 'por',
|
||||
'admin.update.available': 'Actualización disponible',
|
||||
'admin.update.text': 'NOMAD {version} está disponible. Estás usando {current}.',
|
||||
'admin.update.text': 'TREK {version} está disponible. Estás usando {current}.',
|
||||
'admin.update.button': 'Ver en GitHub',
|
||||
'admin.update.install': 'Instalar actualización',
|
||||
'admin.update.confirmTitle': '¿Instalar actualización?',
|
||||
'admin.update.confirmText': 'NOMAD se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.',
|
||||
'admin.update.confirmText': 'TREK se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.',
|
||||
'admin.update.dataInfo': 'Todos tus datos (viajes, usuarios, claves API, subidas, Vacay, Atlas, presupuestos) se conservarán.',
|
||||
'admin.update.warning': 'La app estará brevemente no disponible durante el reinicio.',
|
||||
'admin.update.confirm': 'Actualizar ahora',
|
||||
@@ -477,14 +581,15 @@ const es: Record<string, string> = {
|
||||
'admin.update.backupHint': 'Recomendamos crear una copia de seguridad antes de actualizar.',
|
||||
'admin.update.backupLink': 'Ir a Copia de seguridad',
|
||||
'admin.update.howTo': 'Cómo actualizar',
|
||||
'admin.update.dockerText': 'Tu instancia de NOMAD se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
|
||||
'admin.update.dockerText': 'Tu instancia de TREK se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
|
||||
'admin.update.reloadHint': 'Recarga la página en unos segundos.',
|
||||
|
||||
// Vacay addon
|
||||
'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
|
||||
'vacay.settings': 'Ajustes',
|
||||
'vacay.year': 'Año',
|
||||
'vacay.addYear': 'Añadir año',
|
||||
'vacay.addYear': 'Añadir año siguiente',
|
||||
'vacay.addPrevYear': 'Añadir año anterior',
|
||||
'vacay.removeYear': 'Eliminar año',
|
||||
'vacay.removeYearConfirm': '¿Eliminar {year}?',
|
||||
'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.',
|
||||
@@ -531,9 +636,9 @@ const es: Record<string, string> = {
|
||||
'vacay.carryOver': 'Arrastrar saldo',
|
||||
'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente',
|
||||
'vacay.sharing': 'Compartir',
|
||||
'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de NOMAD',
|
||||
'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de TREK',
|
||||
'vacay.owner': 'Propietario',
|
||||
'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de NOMAD',
|
||||
'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de TREK',
|
||||
'vacay.shareSuccess': 'Plan compartido correctamente',
|
||||
'vacay.shareError': 'No se pudo compartir el plan',
|
||||
'vacay.dissolve': 'Deshacer fusión',
|
||||
@@ -545,7 +650,7 @@ const es: Record<string, string> = {
|
||||
'vacay.noData': 'Sin datos',
|
||||
'vacay.changeColor': 'Cambiar color',
|
||||
'vacay.inviteUser': 'Invitar usuario',
|
||||
'vacay.inviteHint': 'Invita a otro usuario de NOMAD a compartir un calendario combinado de vacaciones.',
|
||||
'vacay.inviteHint': 'Invita a otro usuario de TREK a compartir un calendario combinado de vacaciones.',
|
||||
'vacay.selectUser': 'Seleccionar usuario',
|
||||
'vacay.sendInvite': 'Enviar invitación',
|
||||
'vacay.inviteSent': 'Invitación enviada',
|
||||
@@ -611,13 +716,14 @@ const es: Record<string, string> = {
|
||||
'atlas.unmark': 'Eliminar',
|
||||
'atlas.confirmMark': '¿Marcar este país como visitado?',
|
||||
'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?',
|
||||
'atlas.confirmUnmarkRegion': '¿Eliminar esta región de tu lista de visitados?',
|
||||
'atlas.markVisited': 'Marcar como visitado',
|
||||
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
||||
'atlas.markRegionVisitedHint': 'Añadir esta región a tu lista de visitados',
|
||||
'atlas.addToBucket': 'Añadir a lista de deseos',
|
||||
'atlas.addPoi': 'Añadir lugar',
|
||||
'atlas.bucketNamePlaceholder': 'Nombre (país, ciudad, lugar…)',
|
||||
'atlas.searchCountry': 'Buscar un país...',
|
||||
'atlas.month': 'Mes',
|
||||
'atlas.year': 'Año',
|
||||
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
|
||||
'atlas.bucketWhen': '¿Cuándo planeas visitarlo?',
|
||||
|
||||
@@ -627,9 +733,12 @@ const es: Record<string, string> = {
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de equipaje',
|
||||
'trip.tabs.packingShort': 'Equipaje',
|
||||
'trip.tabs.lists': 'Listas',
|
||||
'trip.tabs.listsShort': 'Listas',
|
||||
'trip.tabs.budget': 'Presupuesto',
|
||||
'trip.tabs.files': 'Archivos',
|
||||
'trip.loading': 'Cargando viaje...',
|
||||
'trip.loadingPhotos': 'Cargando fotos de los lugares...',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Lugares',
|
||||
'trip.toast.placeUpdated': 'Lugar actualizado',
|
||||
@@ -676,9 +785,14 @@ const es: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Añadir lugar/actividad',
|
||||
'places.importGpx': 'Importar GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||
'places.gpxError': 'Error al importar GPX',
|
||||
'places.importGoogleList': 'Lista Google',
|
||||
'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.',
|
||||
'places.googleListImported': '{count} lugares importados de "{list}"',
|
||||
'places.googleListError': 'Error al importar la lista de Google Maps',
|
||||
'places.viewDetails': 'Ver detalles',
|
||||
'places.urlResolved': 'Lugar importado desde URL',
|
||||
'places.assignToDay': '¿A qué día añadirlo?',
|
||||
'places.all': 'Todo',
|
||||
@@ -736,6 +850,7 @@ const es: Record<string, string> = {
|
||||
'inspector.addRes': 'Reserva',
|
||||
'inspector.editRes': 'Editar reserva',
|
||||
'inspector.participants': 'Participantes',
|
||||
'inspector.trackStats': 'Datos de la ruta',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Reservas',
|
||||
@@ -798,9 +913,36 @@ const es: Record<string, string> = {
|
||||
'reservations.linkAssignment': 'Vincular a una asignación del día',
|
||||
'reservations.pickAssignment': 'Selecciona una asignación de tu plan...',
|
||||
'reservations.noAssignment': 'Sin vínculo (independiente)',
|
||||
'reservations.price': 'Precio',
|
||||
'reservations.budgetCategory': 'Categoría de presupuesto',
|
||||
'reservations.budgetCategoryPlaceholder': 'ej. Transporte, Alojamiento',
|
||||
'reservations.budgetCategoryAuto': 'Automático (según tipo de reserva)',
|
||||
'reservations.budgetHint': 'Se creará automáticamente una entrada presupuestaria al guardar.',
|
||||
'reservations.departureDate': 'Salida',
|
||||
'reservations.arrivalDate': 'Llegada',
|
||||
'reservations.departureTime': 'Hora salida',
|
||||
'reservations.arrivalTime': 'Hora llegada',
|
||||
'reservations.pickupDate': 'Recogida',
|
||||
'reservations.returnDate': 'Devolución',
|
||||
'reservations.pickupTime': 'Hora recogida',
|
||||
'reservations.returnTime': 'Hora devolución',
|
||||
'reservations.endDate': 'Fecha fin',
|
||||
'reservations.meta.departureTimezone': 'TZ salida',
|
||||
'reservations.meta.arrivalTimezone': 'TZ llegada',
|
||||
'reservations.span.departure': 'Salida',
|
||||
'reservations.span.arrival': 'Llegada',
|
||||
'reservations.span.inTransit': 'En tránsito',
|
||||
'reservations.span.pickup': 'Recogida',
|
||||
'reservations.span.return': 'Devolución',
|
||||
'reservations.span.active': 'Activo',
|
||||
'reservations.span.start': 'Inicio',
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En curso',
|
||||
'reservations.validation.endBeforeStart': 'La fecha/hora de fin debe ser posterior a la de inicio',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Presupuesto',
|
||||
'budget.exportCsv': 'Exportar CSV',
|
||||
'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto',
|
||||
'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje',
|
||||
'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...',
|
||||
@@ -815,6 +957,7 @@ const es: Record<string, string> = {
|
||||
'budget.table.perDay': 'Por día',
|
||||
'budget.table.perPersonDay': 'Por pers. / día',
|
||||
'budget.table.note': 'Nota',
|
||||
'budget.table.date': 'Fecha',
|
||||
'budget.newEntry': 'Nueva entrada',
|
||||
'budget.defaultEntry': 'Nueva entrada',
|
||||
'budget.defaultCategory': 'Nueva categoría',
|
||||
@@ -1062,10 +1205,12 @@ const es: Record<string, string> = {
|
||||
'photos.linkPlace': 'Vincular lugar',
|
||||
'photos.noPlace': 'Sin lugar',
|
||||
'photos.uploadN': 'Subida de {n} foto(s)',
|
||||
'admin.addons.catalog.memories.name': 'Recuerdos',
|
||||
'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje',
|
||||
'admin.addons.catalog.packing.name': 'Equipaje',
|
||||
'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
|
||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Protocolo de contexto de modelo para integración con asistentes de IA',
|
||||
'admin.addons.catalog.packing.name': 'Listas',
|
||||
'admin.addons.catalog.packing.description': 'Listas de equipaje y tareas pendientes para tus viajes',
|
||||
'admin.addons.catalog.budget.name': 'Presupuesto',
|
||||
'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje',
|
||||
'admin.addons.catalog.documents.name': 'Documentos',
|
||||
@@ -1199,6 +1344,7 @@ const es: Record<string, string> = {
|
||||
'memories.immichUrl': 'URL del servidor Immich',
|
||||
'memories.immichApiKey': 'Clave API',
|
||||
'memories.testConnection': 'Probar conexión',
|
||||
'memories.testFirst': 'Probar conexión primero',
|
||||
'memories.connected': 'Conectado',
|
||||
'memories.disconnected': 'No conectado',
|
||||
'memories.connectionSuccess': 'Conectado a Immich',
|
||||
@@ -1208,6 +1354,12 @@ const es: Record<string, string> = {
|
||||
'memories.newest': 'Más recientes',
|
||||
'memories.allLocations': 'Todas las ubicaciones',
|
||||
'memories.addPhotos': 'Añadir fotos',
|
||||
'memories.linkAlbum': 'Vincular álbum',
|
||||
'memories.selectAlbum': 'Seleccionar álbum de Immich',
|
||||
'memories.noAlbums': 'No se encontraron álbumes',
|
||||
'memories.syncAlbum': 'Sincronizar álbum',
|
||||
'memories.unlinkAlbum': 'Desvincular',
|
||||
'memories.photos': 'fotos',
|
||||
'memories.selectPhotos': 'Seleccionar fotos de Immich',
|
||||
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
|
||||
'memories.selected': 'seleccionado(s)',
|
||||
@@ -1239,6 +1391,7 @@ const es: Record<string, string> = {
|
||||
'collab.chat.today': 'Hoy',
|
||||
'collab.chat.yesterday': 'Ayer',
|
||||
'collab.chat.deletedMessage': 'eliminó un mensaje',
|
||||
'collab.chat.reply': 'Responder',
|
||||
'collab.chat.loadMore': 'Cargar mensajes anteriores',
|
||||
'collab.chat.justNow': 'justo ahora',
|
||||
'collab.chat.minutesAgo': 'hace {n} min',
|
||||
@@ -1340,7 +1493,205 @@ const es: Record<string, string> = {
|
||||
|
||||
// Settings (2.6.2)
|
||||
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
|
||||
'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas y números',
|
||||
'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas, números y un carácter especial',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Permisos',
|
||||
'perm.title': 'Configuración de permisos',
|
||||
'perm.subtitle': 'Controla quién puede realizar acciones en la aplicación',
|
||||
'perm.saved': 'Configuración de permisos guardada',
|
||||
'perm.resetDefaults': 'Restablecer valores predeterminados',
|
||||
'perm.customized': 'personalizado',
|
||||
'perm.level.admin': 'Solo administrador',
|
||||
'perm.level.tripOwner': 'Propietario del viaje',
|
||||
'perm.level.tripMember': 'Miembros del viaje',
|
||||
'perm.level.everybody': 'Todos',
|
||||
'perm.cat.trip': 'Gestión de viajes',
|
||||
'perm.cat.members': 'Gestión de miembros',
|
||||
'perm.cat.files': 'Archivos',
|
||||
'perm.cat.content': 'Contenido y horario',
|
||||
'perm.cat.extras': 'Presupuesto, equipaje y colaboración',
|
||||
'perm.action.trip_create': 'Crear viajes',
|
||||
'perm.action.trip_edit': 'Editar detalles del viaje',
|
||||
'perm.action.trip_delete': 'Eliminar viajes',
|
||||
'perm.action.trip_archive': 'Archivar / desarchivar viajes',
|
||||
'perm.action.trip_cover_upload': 'Subir imagen de portada',
|
||||
'perm.action.member_manage': 'Añadir / eliminar miembros',
|
||||
'perm.action.file_upload': 'Subir archivos',
|
||||
'perm.action.file_edit': 'Editar metadatos del archivo',
|
||||
'perm.action.file_delete': 'Eliminar archivos',
|
||||
'perm.action.place_edit': 'Añadir / editar / eliminar lugares',
|
||||
'perm.action.day_edit': 'Editar días, notas y asignaciones',
|
||||
'perm.action.reservation_edit': 'Gestionar reservas',
|
||||
'perm.action.budget_edit': 'Gestionar presupuesto',
|
||||
'perm.action.packing_edit': 'Gestionar listas de equipaje',
|
||||
'perm.action.collab_edit': 'Colaboración (notas, encuestas, chat)',
|
||||
'perm.action.share_manage': 'Gestionar enlaces compartidos',
|
||||
'perm.actionHint.trip_create': 'Quién puede crear nuevos viajes',
|
||||
'perm.actionHint.trip_edit': 'Quién puede cambiar el nombre, fechas, descripción y moneda del viaje',
|
||||
'perm.actionHint.trip_delete': 'Quién puede eliminar permanentemente un viaje',
|
||||
'perm.actionHint.trip_archive': 'Quién puede archivar o desarchivar un viaje',
|
||||
'perm.actionHint.trip_cover_upload': 'Quién puede subir o cambiar la imagen de portada',
|
||||
'perm.actionHint.member_manage': 'Quién puede invitar o eliminar miembros del viaje',
|
||||
'perm.actionHint.file_upload': 'Quién puede subir archivos a un viaje',
|
||||
'perm.actionHint.file_edit': 'Quién puede editar descripciones y enlaces de archivos',
|
||||
'perm.actionHint.file_delete': 'Quién puede mover archivos a la papelera o eliminarlos permanentemente',
|
||||
'perm.actionHint.place_edit': 'Quién puede añadir, editar o eliminar lugares',
|
||||
'perm.actionHint.day_edit': 'Quién puede editar días, notas de días y asignaciones de lugares',
|
||||
'perm.actionHint.reservation_edit': 'Quién puede crear, editar o eliminar reservas',
|
||||
'perm.actionHint.budget_edit': 'Quién puede crear, editar o eliminar partidas del presupuesto',
|
||||
'perm.actionHint.packing_edit': 'Quién puede gestionar artículos de equipaje y bolsas',
|
||||
'perm.actionHint.collab_edit': 'Quién puede crear notas, encuestas y enviar mensajes',
|
||||
'perm.actionHint.share_manage': 'Quién puede crear o eliminar enlaces compartidos públicos',
|
||||
// Undo
|
||||
'undo.button': 'Deshacer',
|
||||
'undo.tooltip': 'Deshacer: {action}',
|
||||
'undo.assignPlace': 'Lugar asignado al día',
|
||||
'undo.removeAssignment': 'Lugar eliminado del día',
|
||||
'undo.reorder': 'Lugares reordenados',
|
||||
'undo.optimize': 'Ruta optimizada',
|
||||
'undo.deletePlace': 'Lugar eliminado',
|
||||
'undo.moveDay': 'Lugar movido a otro día',
|
||||
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
||||
'undo.importGpx': 'Importación GPX',
|
||||
'undo.importGoogleList': 'Importación de Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Notificaciones',
|
||||
'notifications.markAllRead': 'Marcar todo como leído',
|
||||
'notifications.deleteAll': 'Eliminar todo',
|
||||
'notifications.showAll': 'Ver todas las notificaciones',
|
||||
'notifications.empty': 'Sin notificaciones',
|
||||
'notifications.emptyDescription': '¡Estás al día!',
|
||||
'notifications.all': 'Todas',
|
||||
'notifications.unreadOnly': 'No leídas',
|
||||
'notifications.markRead': 'Marcar como leída',
|
||||
'notifications.markUnread': 'Marcar como no leída',
|
||||
'notifications.delete': 'Eliminar',
|
||||
'notifications.system': 'Sistema',
|
||||
'memories.error.loadAlbums': 'Error al cargar los álbumes',
|
||||
'memories.error.linkAlbum': 'Error al vincular el álbum',
|
||||
'memories.error.unlinkAlbum': 'Error al desvincular el álbum',
|
||||
'memories.error.syncAlbum': 'Error al sincronizar el álbum',
|
||||
'memories.error.loadPhotos': 'Error al cargar las fotos',
|
||||
'memories.error.addPhotos': 'Error al agregar las fotos',
|
||||
'memories.error.removePhoto': 'Error al eliminar la foto',
|
||||
'memories.error.toggleSharing': 'Error al actualizar el uso compartido',
|
||||
'undo.addPlace': 'Lugar agregado',
|
||||
'undo.done': 'Deshecho: {action}',
|
||||
'notifications.test.title': 'Notificación de prueba de {actor}',
|
||||
'notifications.test.text': 'Esta es una notificación de prueba simple.',
|
||||
'notifications.test.booleanTitle': '{actor} solicita tu aprobación',
|
||||
'notifications.test.booleanText': 'Notificación de prueba booleana.',
|
||||
'notifications.test.accept': 'Aprobar',
|
||||
'notifications.test.decline': 'Rechazar',
|
||||
'notifications.test.navigateTitle': 'Mira esto',
|
||||
'notifications.test.navigateText': 'Notificación de prueba de navegación.',
|
||||
'notifications.test.goThere': 'Ir allí',
|
||||
'notifications.test.adminTitle': 'Difusión de administrador',
|
||||
'notifications.test.adminText': '{actor} envió una notificación de prueba a todos los administradores.',
|
||||
'notifications.test.tripTitle': '{actor} publicó en tu viaje',
|
||||
'notifications.test.tripText': 'Notificación de prueba para el viaje "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Lista de equipaje',
|
||||
'todo.subtab.todo': 'Por hacer',
|
||||
'todo.completed': 'completado(s)',
|
||||
'todo.filter.all': 'Todo',
|
||||
'todo.filter.open': 'Abierto',
|
||||
'todo.filter.done': 'Hecho',
|
||||
'todo.uncategorized': 'Sin categoría',
|
||||
'todo.namePlaceholder': 'Nombre de la tarea',
|
||||
'todo.descriptionPlaceholder': 'Descripción (opcional)',
|
||||
'todo.unassigned': 'Sin asignar',
|
||||
'todo.noCategory': 'Sin categoría',
|
||||
'todo.hasDescription': 'Con descripción',
|
||||
'todo.addItem': 'Añadir nueva tarea...',
|
||||
'todo.newCategory': 'Nombre de la categoría',
|
||||
'todo.addCategory': 'Añadir categoría',
|
||||
'todo.newItem': 'Nueva tarea',
|
||||
'todo.empty': 'Aún no hay tareas. ¡Añade una tarea para empezar!',
|
||||
'todo.filter.my': 'Mis tareas',
|
||||
'todo.filter.overdue': 'Vencida',
|
||||
'todo.sidebar.tasks': 'Tareas',
|
||||
'todo.sidebar.categories': 'Categorías',
|
||||
'todo.detail.title': 'Tarea',
|
||||
'todo.detail.description': 'Descripción',
|
||||
'todo.detail.category': 'Categoría',
|
||||
'todo.detail.dueDate': 'Fecha límite',
|
||||
'todo.detail.assignedTo': 'Asignado a',
|
||||
'todo.detail.delete': 'Eliminar',
|
||||
'todo.detail.save': 'Guardar cambios',
|
||||
'todo.detail.create': 'Crear tarea',
|
||||
'todo.detail.priority': 'Prioridad',
|
||||
'todo.detail.noPriority': 'Ninguna',
|
||||
'todo.sortByPrio': 'Prioridad',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'Nueva versión disponible',
|
||||
'settings.notificationPreferences.noChannels': 'No hay canales de notificación configurados. Pide a un administrador que configure notificaciones por correo o webhook.',
|
||||
'settings.webhookUrl.label': 'URL del webhook',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Introduce tu URL de webhook de Discord, Slack o personalizada para recibir notificaciones.',
|
||||
'settings.webhookUrl.save': 'Guardar',
|
||||
'settings.webhookUrl.saved': 'URL del webhook guardada',
|
||||
'settings.webhookUrl.test': 'Probar',
|
||||
'settings.webhookUrl.testSuccess': 'Webhook de prueba enviado correctamente',
|
||||
'settings.webhookUrl.testFailed': 'Error al enviar el webhook de prueba',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'Las notificaciones in-app siempre están activas y no se pueden desactivar globalmente.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Webhook de admin',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Este webhook se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los webhooks de usuario y se activa automáticamente si hay una URL configurada.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'URL del webhook de admin guardada',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de prueba enviado correctamente',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Error al enviar el webhook de prueba',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'El webhook de admin se activa automáticamente si hay una URL configurada',
|
||||
'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
|
||||
'admin.tabs.notifications': 'Notificaciones',
|
||||
'notifications.versionAvailable.title': 'Actualización disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
|
||||
'notifications.versionAvailable.button': 'Ver detalles',
|
||||
'notif.test.title': '[Test] Notificación',
|
||||
'notif.test.simple.text': 'Esta es una notificación de prueba simple.',
|
||||
'notif.test.boolean.text': '¿Aceptas esta notificación de prueba?',
|
||||
'notif.test.navigate.text': 'Haz clic abajo para ir al panel de control.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Invitación al viaje',
|
||||
'notif.trip_invite.text': '{actor} te invitó a {trip}',
|
||||
'notif.booking_change.title': 'Reserva actualizada',
|
||||
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
|
||||
'notif.trip_reminder.title': 'Recordatorio de viaje',
|
||||
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
|
||||
'notif.vacay_invite.title': 'Invitación Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones',
|
||||
'notif.photos_shared.title': 'Fotos compartidas',
|
||||
'notif.photos_shared.text': '{actor} compartió {count} foto(s) en {trip}',
|
||||
'notif.collab_message.title': 'Nuevo mensaje',
|
||||
'notif.collab_message.text': '{actor} envió un mensaje en {trip}',
|
||||
'notif.packing_tagged.title': 'Asignación de equipaje',
|
||||
'notif.packing_tagged.text': '{actor} te asignó a {category} en {trip}',
|
||||
'notif.version_available.title': 'Nueva versión disponible',
|
||||
'notif.version_available.text': 'TREK {version} ya está disponible',
|
||||
'notif.action.view_trip': 'Ver viaje',
|
||||
'notif.action.view_collab': 'Ver mensajes',
|
||||
'notif.action.view_packing': 'Ver equipaje',
|
||||
'notif.action.view_photos': 'Ver fotos',
|
||||
'notif.action.view_vacay': 'Ver Vacay',
|
||||
'notif.action.view_admin': 'Ir al admin',
|
||||
'notif.action.view': 'Ver',
|
||||
'notif.action.accept': 'Aceptar',
|
||||
'notif.action.decline': 'Rechazar',
|
||||
'notif.generic.title': 'Notificación',
|
||||
'notif.generic.text': 'Tienes una nueva notificación',
|
||||
'notif.dev.unknown_event.title': '[DEV] Evento desconocido',
|
||||
'notif.dev.unknown_event.text': 'El tipo de evento "{event}" no está registrado en EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default es
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const fr: Record<string, string> = {
|
||||
'common.edit': 'Modifier',
|
||||
'common.add': 'Ajouter',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.import': 'Importer',
|
||||
'common.error': 'Erreur',
|
||||
'common.back': 'Retour',
|
||||
'common.all': 'Tout',
|
||||
@@ -25,6 +26,14 @@ const fr: Record<string, string> = {
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Mot de passe',
|
||||
'common.saving': 'Enregistrement…',
|
||||
'common.saved': 'Enregistré',
|
||||
'trips.reminder': 'Rappel',
|
||||
'trips.reminderNone': 'Aucun',
|
||||
'trips.reminderDay': 'jour',
|
||||
'trips.reminderDays': 'jours',
|
||||
'trips.reminderCustom': 'Personnalisé',
|
||||
'trips.reminderDaysBefore': 'jours avant le départ',
|
||||
'trips.reminderDisabledHint': 'Les rappels de voyage sont désactivés. Activez-les dans Admin > Paramètres > Notifications.',
|
||||
'common.update': 'Mettre à jour',
|
||||
'common.change': 'Modifier',
|
||||
'common.uploading': 'Import en cours…',
|
||||
@@ -71,7 +80,10 @@ const fr: Record<string, string> = {
|
||||
'dashboard.sharedBy': 'Partagé par {name}',
|
||||
'dashboard.days': 'Jours',
|
||||
'dashboard.places': 'Lieux',
|
||||
'dashboard.members': 'Compagnons de voyage',
|
||||
'dashboard.archive': 'Archiver',
|
||||
'dashboard.copyTrip': 'Copier',
|
||||
'dashboard.copySuffix': 'copie',
|
||||
'dashboard.restore': 'Restaurer',
|
||||
'dashboard.archived': 'Archivé',
|
||||
'dashboard.status.ongoing': 'En cours',
|
||||
@@ -90,6 +102,8 @@ const fr: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': "Impossible d'archiver le voyage",
|
||||
'dashboard.toast.restored': 'Voyage restauré',
|
||||
'dashboard.toast.restoreError': 'Impossible de restaurer le voyage',
|
||||
'dashboard.toast.copied': 'Voyage copié !',
|
||||
'dashboard.toast.copyError': 'Impossible de copier le voyage',
|
||||
'dashboard.confirm.delete': 'Supprimer le voyage « {title} » ? Tous les lieux et plans seront définitivement supprimés.',
|
||||
'dashboard.editTrip': 'Modifier le voyage',
|
||||
'dashboard.createTrip': 'Créer un nouveau voyage',
|
||||
@@ -99,6 +113,8 @@ const fr: Record<string, string> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'De quoi parle ce voyage ?',
|
||||
'dashboard.startDate': 'Date de début',
|
||||
'dashboard.endDate': 'Date de fin',
|
||||
'dashboard.dayCount': 'Nombre de jours',
|
||||
'dashboard.dayCountHint': 'Nombre de jours à planifier lorsqu\'aucune date de voyage n\'est définie.',
|
||||
'dashboard.noDateHint': 'Aucune date définie — 7 jours par défaut seront créés. Vous pouvez modifier cela à tout moment.',
|
||||
'dashboard.coverImage': 'Image de couverture',
|
||||
'dashboard.addCoverImage': 'Ajouter une image de couverture',
|
||||
@@ -113,6 +129,12 @@ const fr: Record<string, string> = {
|
||||
// Settings
|
||||
'settings.title': 'Paramètres',
|
||||
'settings.subtitle': 'Configurez vos paramètres personnels',
|
||||
'settings.tabs.display': 'Affichage',
|
||||
'settings.tabs.map': 'Carte',
|
||||
'settings.tabs.notifications': 'Notifications',
|
||||
'settings.tabs.integrations': 'Intégrations',
|
||||
'settings.tabs.account': 'Compte',
|
||||
'settings.tabs.about': 'À propos',
|
||||
'settings.map': 'Carte',
|
||||
'settings.mapTemplate': 'Modèle de carte',
|
||||
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…',
|
||||
@@ -149,9 +171,26 @@ const fr: Record<string, string> = {
|
||||
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
|
||||
'settings.notifyPackingTagged': 'Liste de bagages : attributions',
|
||||
'settings.notifyWebhook': 'Notifications webhook',
|
||||
'settings.notificationsDisabled': 'Les notifications ne sont pas configurées. Demandez à un administrateur d\'activer les notifications par e-mail ou webhook.',
|
||||
'settings.notificationsActive': 'Canal actif',
|
||||
'settings.notificationsManagedByAdmin': 'Les événements de notification sont configurés par votre administrateur.',
|
||||
'admin.notifications.title': 'Notifications',
|
||||
'admin.notifications.hint': 'Choisissez un canal de notification. Un seul peut être actif à la fois.',
|
||||
'admin.notifications.none': 'Désactivé',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Événements de notification',
|
||||
'admin.notifications.eventsHint': 'Choisissez quels événements déclenchent des notifications pour tous les utilisateurs.',
|
||||
'admin.notifications.configureFirst': 'Configurez d\'abord les paramètres SMTP ou webhook ci-dessous, puis activez les événements.',
|
||||
'admin.notifications.save': 'Enregistrer les paramètres de notification',
|
||||
'admin.notifications.saved': 'Paramètres de notification enregistrés',
|
||||
'admin.notifications.testWebhook': 'Envoyer un webhook de test',
|
||||
'admin.notifications.testWebhookSuccess': 'Webhook de test envoyé avec succès',
|
||||
'admin.notifications.testWebhookFailed': 'Échec du webhook de test',
|
||||
'admin.smtp.title': 'E-mail et notifications',
|
||||
'admin.smtp.hint': 'Configuration SMTP pour les notifications par e-mail. Optionnel : URL webhook pour Discord, Slack, etc.',
|
||||
'admin.smtp.hint': 'Configuration SMTP pour l\'envoi des notifications par e-mail.',
|
||||
'admin.smtp.testButton': 'Envoyer un e-mail de test',
|
||||
'admin.webhook.hint': 'Envoyer des notifications vers un webhook externe (Discord, Slack, etc.).',
|
||||
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
|
||||
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
|
||||
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
|
||||
@@ -185,13 +224,48 @@ const fr: Record<string, string> = {
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'Activé',
|
||||
'settings.off': 'Désactivé',
|
||||
'settings.mcp.title': 'Configuration MCP',
|
||||
'settings.mcp.endpoint': 'Point de terminaison MCP',
|
||||
'settings.mcp.clientConfig': 'Configuration du client',
|
||||
'settings.mcp.clientConfigHint': 'Remplacez <your_token> par un token API de la liste ci-dessous. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).',
|
||||
'settings.mcp.copy': 'Copier',
|
||||
'settings.mcp.copied': 'Copié !',
|
||||
'settings.mcp.apiTokens': 'Tokens API',
|
||||
'settings.mcp.createToken': 'Créer un token',
|
||||
'settings.mcp.noTokens': 'Aucun token pour l\'instant. Créez-en un pour connecter des clients MCP.',
|
||||
'settings.mcp.tokenCreatedAt': 'Créé',
|
||||
'settings.mcp.tokenUsedAt': 'Utilisé',
|
||||
'settings.mcp.deleteTokenTitle': 'Supprimer le token',
|
||||
'settings.mcp.deleteTokenMessage': 'Ce token cessera de fonctionner immédiatement. Tout client MCP l\'utilisant perdra l\'accès.',
|
||||
'settings.mcp.modal.createTitle': 'Créer un token API',
|
||||
'settings.mcp.modal.tokenName': 'Nom du token',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'ex. Claude Desktop, Ordinateur pro',
|
||||
'settings.mcp.modal.creating': 'Création…',
|
||||
'settings.mcp.modal.create': 'Créer le token',
|
||||
'settings.mcp.modal.createdTitle': 'Token créé',
|
||||
'settings.mcp.modal.createdWarning': 'Ce token ne sera affiché qu\'une seule fois. Copiez-le et conservez-le maintenant — il ne pourra pas être récupéré.',
|
||||
'settings.mcp.modal.done': 'Terminé',
|
||||
'settings.mcp.toast.created': 'Token créé',
|
||||
'settings.mcp.toast.createError': 'Impossible de créer le token',
|
||||
'settings.mcp.toast.deleted': 'Token supprimé',
|
||||
'settings.mcp.toast.deleteError': 'Impossible de supprimer le token',
|
||||
'settings.account': 'Compte',
|
||||
'settings.about': 'À propos',
|
||||
'settings.about.reportBug': 'Signaler un bug',
|
||||
'settings.about.reportBugHint': 'Un problème ? Faites-le nous savoir',
|
||||
'settings.about.featureRequest': 'Proposer une fonctionnalité',
|
||||
'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité',
|
||||
'settings.about.wikiHint': 'Documentation et guides',
|
||||
'settings.about.description': 'TREK est un planificateur de voyage auto-hébergé qui vous aide à organiser vos voyages de la première idée au dernier souvenir. Planification journalière, budget, listes de bagages, photos et bien plus — le tout au même endroit, sur votre propre serveur.',
|
||||
'settings.about.madeWith': 'Fait avec',
|
||||
'settings.about.madeBy': 'par Maurice et une communauté open-source grandissante.',
|
||||
'settings.username': 'Nom d\'utilisateur',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Rôle',
|
||||
'settings.roleAdmin': 'Administrateur',
|
||||
'settings.oidcLinked': 'Lié avec',
|
||||
'settings.changePassword': 'Changer le mot de passe',
|
||||
'settings.mustChangePassword': 'Vous devez changer votre mot de passe avant de continuer. Veuillez définir un nouveau mot de passe ci-dessous.',
|
||||
'settings.currentPassword': 'Mot de passe actuel',
|
||||
'settings.currentPasswordRequired': 'Le mot de passe actuel est requis',
|
||||
'settings.newPassword': 'Nouveau mot de passe',
|
||||
@@ -200,7 +274,7 @@ const fr: Record<string, string> = {
|
||||
'settings.passwordRequired': 'Veuillez saisir le mot de passe actuel et le nouveau',
|
||||
'settings.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères',
|
||||
'settings.passwordMismatch': 'Les mots de passe ne correspondent pas',
|
||||
'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules et un chiffre',
|
||||
'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules, un chiffre et un caractère spécial',
|
||||
'settings.passwordChanged': 'Mot de passe modifié avec succès',
|
||||
'settings.deleteAccount': 'Supprimer le compte',
|
||||
'settings.deleteAccountTitle': 'Supprimer votre compte ?',
|
||||
@@ -212,6 +286,14 @@ const fr: Record<string, string> = {
|
||||
'settings.saveProfile': 'Enregistrer le profil',
|
||||
'settings.mfa.title': 'Authentification à deux facteurs (2FA)',
|
||||
'settings.mfa.description': 'Ajoute une étape supplémentaire lors de la connexion. Utilisez une application d\'authentification (Google Authenticator, Authy, etc.).',
|
||||
'settings.mfa.requiredByPolicy': 'Votre administrateur exige l\'authentification à deux facteurs. Configurez une application d\'authentification ci-dessous avant de continuer.',
|
||||
'settings.mfa.backupTitle': 'Codes de secours',
|
||||
'settings.mfa.backupDescription': 'Utilisez ces codes à usage unique si vous perdez l\'accès à votre application d\'authentification.',
|
||||
'settings.mfa.backupWarning': 'Enregistrez ces codes maintenant. Chaque code n\'est utilisable qu\'une seule fois.',
|
||||
'settings.mfa.backupCopy': 'Copier les codes',
|
||||
'settings.mfa.backupDownload': 'Télécharger TXT',
|
||||
'settings.mfa.backupPrint': 'Imprimer / PDF',
|
||||
'settings.mfa.backupCopied': 'Codes de secours copiés',
|
||||
'settings.mfa.enabled': '2FA est activé sur votre compte.',
|
||||
'settings.mfa.disabled': '2FA n\'est pas activé.',
|
||||
'settings.mfa.setup': 'Configurer l\'authentificateur',
|
||||
@@ -263,6 +345,8 @@ const fr: Record<string, string> = {
|
||||
'login.signIn': 'Se connecter',
|
||||
'login.createAdmin': 'Créer un compte administrateur',
|
||||
'login.createAdminHint': 'Configurez le premier compte administrateur pour TREK.',
|
||||
'login.setNewPassword': 'Définir un nouveau mot de passe',
|
||||
'login.setNewPasswordHint': 'Vous devez changer votre mot de passe avant de continuer.',
|
||||
'login.createAccount': 'Créer un compte',
|
||||
'login.createAccountHint': 'Créez un nouveau compte.',
|
||||
'login.creating': 'Création…',
|
||||
@@ -289,7 +373,7 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Les mots de passe ne correspondent pas',
|
||||
'register.passwordTooShort': 'Le mot de passe doit comporter au moins 6 caractères',
|
||||
'register.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères',
|
||||
'register.failed': 'Échec de l\'inscription',
|
||||
'register.getStarted': 'Commencer',
|
||||
'register.subtitle': 'Créez un compte et commencez à planifier vos voyages de rêve.',
|
||||
@@ -364,6 +448,8 @@ const fr: Record<string, string> = {
|
||||
'admin.tabs.settings': 'Paramètres',
|
||||
'admin.allowRegistration': 'Autoriser les inscriptions',
|
||||
'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
|
||||
'admin.requireMfa': 'Exiger l\'authentification à deux facteurs (2FA)',
|
||||
'admin.requireMfaHint': 'Les utilisateurs sans 2FA doivent terminer la configuration dans Paramètres avant d\'utiliser l\'application.',
|
||||
'admin.apiKeys': 'Clés API',
|
||||
'admin.apiKeysHint': 'Facultatif. Active les données de lieu étendues comme les photos et la météo.',
|
||||
'admin.mapsKey': 'Clé API Google Maps',
|
||||
@@ -393,7 +479,7 @@ const fr: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': 'Suivi des bagages',
|
||||
'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles',
|
||||
'admin.tabs.config': 'Configuration',
|
||||
'admin.tabs.config': 'Personnalisation',
|
||||
'admin.tabs.templates': 'Modèles de bagages',
|
||||
'admin.packingTemplates.title': 'Modèles de bagages',
|
||||
'admin.packingTemplates.subtitle': 'Créer des listes de bagages réutilisables pour vos voyages',
|
||||
@@ -417,10 +503,12 @@ const fr: Record<string, string> = {
|
||||
'admin.tabs.addons': 'Extensions',
|
||||
'admin.addons.title': 'Extensions',
|
||||
'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.',
|
||||
'admin.addons.catalog.memories.name': 'Souvenirs',
|
||||
'admin.addons.catalog.memories.description': 'Albums photo partagés pour chaque voyage',
|
||||
'admin.addons.catalog.packing.name': 'Bagages',
|
||||
'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage',
|
||||
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Protocole de contexte de modèle pour l\'intégration d\'assistants IA',
|
||||
'admin.addons.catalog.packing.name': 'Listes',
|
||||
'admin.addons.catalog.packing.description': 'Listes de bagages et tâches à faire pour vos voyages',
|
||||
'admin.addons.catalog.budget.name': 'Budget',
|
||||
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
|
||||
'admin.addons.catalog.documents.name': 'Documents',
|
||||
@@ -437,8 +525,10 @@ const fr: Record<string, string> = {
|
||||
'admin.addons.disabled': 'Désactivé',
|
||||
'admin.addons.type.trip': 'Voyage',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.integration': 'Intégration',
|
||||
'admin.addons.tripHint': 'Disponible comme onglet dans chaque voyage',
|
||||
'admin.addons.globalHint': 'Disponible comme section autonome dans la navigation principale',
|
||||
'admin.addons.integrationHint': 'Services backend et intégrations API sans page dédiée',
|
||||
'admin.addons.toast.updated': 'Extension mise à jour',
|
||||
'admin.addons.toast.error': 'Échec de la mise à jour de l\'extension',
|
||||
'admin.addons.noAddons': 'Aucune extension disponible',
|
||||
@@ -454,7 +544,7 @@ const fr: Record<string, string> = {
|
||||
'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise',
|
||||
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est attribué à un jour, un lieu de la liste est utilisé comme référence.',
|
||||
|
||||
'admin.tabs.audit': 'Journal d\'audit',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
|
||||
'admin.audit.subtitle': 'Événements sensibles de sécurité et d\'administration (sauvegardes, utilisateurs, 2FA, paramètres).',
|
||||
'admin.audit.empty': 'Aucune entrée d\'audit.',
|
||||
@@ -468,6 +558,22 @@ const fr: Record<string, string> = {
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Détails',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'Tokens MCP',
|
||||
'admin.mcpTokens.title': 'Tokens MCP',
|
||||
'admin.mcpTokens.subtitle': 'Gérer les tokens API de tous les utilisateurs',
|
||||
'admin.mcpTokens.owner': 'Propriétaire',
|
||||
'admin.mcpTokens.tokenName': 'Nom du token',
|
||||
'admin.mcpTokens.created': 'Créé',
|
||||
'admin.mcpTokens.lastUsed': 'Dernière utilisation',
|
||||
'admin.mcpTokens.never': 'Jamais',
|
||||
'admin.mcpTokens.empty': 'Aucun token MCP n\'a encore été créé',
|
||||
'admin.mcpTokens.deleteTitle': 'Supprimer le token',
|
||||
'admin.mcpTokens.deleteMessage': 'Ce token sera révoqué immédiatement. L\'utilisateur perdra l\'accès MCP via ce token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token supprimé',
|
||||
'admin.mcpTokens.deleteError': 'Impossible de supprimer le token',
|
||||
'admin.mcpTokens.loadError': 'Impossible de charger les tokens',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.github.title': 'Historique des versions',
|
||||
@@ -504,7 +610,8 @@ const fr: Record<string, string> = {
|
||||
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
|
||||
'vacay.settings': 'Paramètres',
|
||||
'vacay.year': 'Année',
|
||||
'vacay.addYear': 'Ajouter une année',
|
||||
'vacay.addYear': 'Ajouter l\'année suivante',
|
||||
'vacay.addPrevYear': 'Ajouter l\'année précédente',
|
||||
'vacay.removeYear': 'Supprimer l\'année',
|
||||
'vacay.removeYearConfirm': 'Supprimer {year} ?',
|
||||
'vacay.removeYearHint': 'Toutes les entrées de vacances et jours fériés d\'entreprise de cette année seront définitivement supprimés.',
|
||||
@@ -632,13 +739,14 @@ const fr: Record<string, string> = {
|
||||
'atlas.unmark': 'Retirer',
|
||||
'atlas.confirmMark': 'Marquer ce pays comme visité ?',
|
||||
'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?',
|
||||
'atlas.confirmUnmarkRegion': 'Retirer cette région de votre liste ?',
|
||||
'atlas.markVisited': 'Marquer comme visité',
|
||||
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
|
||||
'atlas.markRegionVisitedHint': 'Ajouter cette région à votre liste de visités',
|
||||
'atlas.addToBucket': 'Ajouter à la bucket list',
|
||||
'atlas.addPoi': 'Ajouter un lieu',
|
||||
'atlas.bucketNamePlaceholder': 'Nom (pays, ville, lieu…)',
|
||||
'atlas.searchCountry': 'Rechercher un pays…',
|
||||
'atlas.month': 'Mois',
|
||||
'atlas.year': 'Année',
|
||||
'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter',
|
||||
'atlas.bucketWhen': 'Quand prévoyez-vous d\'y aller ?',
|
||||
|
||||
@@ -648,9 +756,12 @@ const fr: Record<string, string> = {
|
||||
'trip.tabs.reservationsShort': 'Résa',
|
||||
'trip.tabs.packing': 'Liste de bagages',
|
||||
'trip.tabs.packingShort': 'Bagages',
|
||||
'trip.tabs.lists': 'Listes',
|
||||
'trip.tabs.listsShort': 'Listes',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Fichiers',
|
||||
'trip.loading': 'Chargement du voyage…',
|
||||
'trip.loadingPhotos': 'Chargement des photos des lieux...',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Lieux',
|
||||
'trip.toast.placeUpdated': 'Lieu mis à jour',
|
||||
@@ -697,9 +808,14 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ajouter un lieu/activité',
|
||||
'places.importGpx': 'Importer GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} lieux importés depuis GPX',
|
||||
'places.gpxError': 'L\'import GPX a échoué',
|
||||
'places.importGoogleList': 'Liste Google',
|
||||
'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.',
|
||||
'places.googleListImported': '{count} lieux importés depuis "{list}"',
|
||||
'places.googleListError': 'Impossible d\'importer la liste Google Maps',
|
||||
'places.viewDetails': 'Voir les détails',
|
||||
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
||||
'places.assignToDay': 'Ajouter à quel jour ?',
|
||||
'places.all': 'Tous',
|
||||
@@ -756,6 +872,7 @@ const fr: Record<string, string> = {
|
||||
'inspector.addRes': 'Réservation',
|
||||
'inspector.editRes': 'Modifier la réservation',
|
||||
'inspector.participants': 'Participants',
|
||||
'inspector.trackStats': 'Données du parcours',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Réservations',
|
||||
@@ -835,9 +952,36 @@ const fr: Record<string, string> = {
|
||||
'reservations.linkAssignment': 'Lier à l\'affectation du jour',
|
||||
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…',
|
||||
'reservations.noAssignment': 'Aucun lien (autonome)',
|
||||
'reservations.price': 'Prix',
|
||||
'reservations.budgetCategory': 'Catégorie budgétaire',
|
||||
'reservations.budgetCategoryPlaceholder': 'ex. Transport, Hébergement',
|
||||
'reservations.budgetCategoryAuto': 'Auto (selon le type de réservation)',
|
||||
'reservations.budgetHint': 'Une entrée budgétaire sera créée automatiquement lors de l\'enregistrement.',
|
||||
'reservations.departureDate': 'Départ',
|
||||
'reservations.arrivalDate': 'Arrivée',
|
||||
'reservations.departureTime': 'Heure dép.',
|
||||
'reservations.arrivalTime': 'Heure arr.',
|
||||
'reservations.pickupDate': 'Prise en charge',
|
||||
'reservations.returnDate': 'Restitution',
|
||||
'reservations.pickupTime': 'Heure prise en charge',
|
||||
'reservations.returnTime': 'Heure restitution',
|
||||
'reservations.endDate': 'Date de fin',
|
||||
'reservations.meta.departureTimezone': 'TZ dép.',
|
||||
'reservations.meta.arrivalTimezone': 'TZ arr.',
|
||||
'reservations.span.departure': 'Départ',
|
||||
'reservations.span.arrival': 'Arrivée',
|
||||
'reservations.span.inTransit': 'En transit',
|
||||
'reservations.span.pickup': 'Prise en charge',
|
||||
'reservations.span.return': 'Restitution',
|
||||
'reservations.span.active': 'Actif',
|
||||
'reservations.span.start': 'Début',
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En cours',
|
||||
'reservations.validation.endBeforeStart': 'La date/heure de fin doit être postérieure à la date/heure de début',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.exportCsv': 'Exporter CSV',
|
||||
'budget.emptyTitle': 'Aucun budget créé',
|
||||
'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage',
|
||||
'budget.emptyPlaceholder': 'Nom de la catégorie…',
|
||||
@@ -852,6 +996,7 @@ const fr: Record<string, string> = {
|
||||
'budget.table.perDay': 'Par jour',
|
||||
'budget.table.perPersonDay': 'P. p / Jour',
|
||||
'budget.table.note': 'Note',
|
||||
'budget.table.date': 'Date',
|
||||
'budget.newEntry': 'Nouvelle entrée',
|
||||
'budget.defaultEntry': 'Nouvelle entrée',
|
||||
'budget.defaultCategory': 'Nouvelle catégorie',
|
||||
@@ -1245,6 +1390,7 @@ const fr: Record<string, string> = {
|
||||
'memories.immichUrl': 'URL du serveur Immich',
|
||||
'memories.immichApiKey': 'Clé API',
|
||||
'memories.testConnection': 'Tester la connexion',
|
||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||
'memories.connected': 'Connecté',
|
||||
'memories.disconnected': 'Non connecté',
|
||||
'memories.connectionSuccess': 'Connecté à Immich',
|
||||
@@ -1254,6 +1400,12 @@ const fr: Record<string, string> = {
|
||||
'memories.newest': 'Plus récentes',
|
||||
'memories.allLocations': 'Tous les lieux',
|
||||
'memories.addPhotos': 'Ajouter des photos',
|
||||
'memories.linkAlbum': 'Lier un album',
|
||||
'memories.selectAlbum': 'Choisir un album Immich',
|
||||
'memories.noAlbums': 'Aucun album trouvé',
|
||||
'memories.syncAlbum': 'Synchroniser',
|
||||
'memories.unlinkAlbum': 'Délier',
|
||||
'memories.photos': 'photos',
|
||||
'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
|
||||
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
|
||||
'memories.selected': 'sélectionné(s)',
|
||||
@@ -1285,6 +1437,7 @@ const fr: Record<string, string> = {
|
||||
'collab.chat.today': 'Aujourd\'hui',
|
||||
'collab.chat.yesterday': 'Hier',
|
||||
'collab.chat.deletedMessage': 'a supprimé un message',
|
||||
'collab.chat.reply': 'Répondre',
|
||||
'collab.chat.loadMore': 'Charger les messages précédents',
|
||||
'collab.chat.justNow': 'à l\'instant',
|
||||
'collab.chat.minutesAgo': 'il y a {n} min',
|
||||
@@ -1335,6 +1488,204 @@ const fr: Record<string, string> = {
|
||||
'collab.polls.options': 'Options',
|
||||
'collab.polls.delete': 'Supprimer',
|
||||
'collab.polls.closedSection': 'Fermés',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Permissions',
|
||||
'perm.title': 'Paramètres des permissions',
|
||||
'perm.subtitle': 'Contrôlez qui peut effectuer des actions dans l\'application',
|
||||
'perm.saved': 'Paramètres des permissions enregistrés',
|
||||
'perm.resetDefaults': 'Réinitialiser par défaut',
|
||||
'perm.customized': 'personnalisé',
|
||||
'perm.level.admin': 'Administrateur uniquement',
|
||||
'perm.level.tripOwner': 'Propriétaire du voyage',
|
||||
'perm.level.tripMember': 'Membres du voyage',
|
||||
'perm.level.everybody': 'Tout le monde',
|
||||
'perm.cat.trip': 'Gestion des voyages',
|
||||
'perm.cat.members': 'Gestion des membres',
|
||||
'perm.cat.files': 'Fichiers',
|
||||
'perm.cat.content': 'Contenu et planning',
|
||||
'perm.cat.extras': 'Budget, bagages et collaboration',
|
||||
'perm.action.trip_create': 'Créer des voyages',
|
||||
'perm.action.trip_edit': 'Modifier les détails du voyage',
|
||||
'perm.action.trip_delete': 'Supprimer des voyages',
|
||||
'perm.action.trip_archive': 'Archiver / désarchiver des voyages',
|
||||
'perm.action.trip_cover_upload': 'Télécharger l\'image de couverture',
|
||||
'perm.action.member_manage': 'Ajouter / supprimer des membres',
|
||||
'perm.action.file_upload': 'Télécharger des fichiers',
|
||||
'perm.action.file_edit': 'Modifier les métadonnées des fichiers',
|
||||
'perm.action.file_delete': 'Supprimer des fichiers',
|
||||
'perm.action.place_edit': 'Ajouter / modifier / supprimer des lieux',
|
||||
'perm.action.day_edit': 'Modifier les jours, notes et affectations',
|
||||
'perm.action.reservation_edit': 'Gérer les réservations',
|
||||
'perm.action.budget_edit': 'Gérer le budget',
|
||||
'perm.action.packing_edit': 'Gérer les listes de bagages',
|
||||
'perm.action.collab_edit': 'Collaboration (notes, sondages, chat)',
|
||||
'perm.action.share_manage': 'Gérer les liens de partage',
|
||||
'perm.actionHint.trip_create': 'Qui peut créer de nouveaux voyages',
|
||||
'perm.actionHint.trip_edit': 'Qui peut modifier le nom, les dates, la description et la devise du voyage',
|
||||
'perm.actionHint.trip_delete': 'Qui peut supprimer définitivement un voyage',
|
||||
'perm.actionHint.trip_archive': 'Qui peut archiver ou désarchiver un voyage',
|
||||
'perm.actionHint.trip_cover_upload': 'Qui peut télécharger ou modifier l\'image de couverture',
|
||||
'perm.actionHint.member_manage': 'Qui peut inviter ou supprimer des membres du voyage',
|
||||
'perm.actionHint.file_upload': 'Qui peut télécharger des fichiers vers un voyage',
|
||||
'perm.actionHint.file_edit': 'Qui peut modifier les descriptions et liens des fichiers',
|
||||
'perm.actionHint.file_delete': 'Qui peut déplacer des fichiers vers la corbeille ou les supprimer définitivement',
|
||||
'perm.actionHint.place_edit': 'Qui peut ajouter, modifier ou supprimer des lieux',
|
||||
'perm.actionHint.day_edit': 'Qui peut modifier les jours, notes de jours et affectations de lieux',
|
||||
'perm.actionHint.reservation_edit': 'Qui peut créer, modifier ou supprimer des réservations',
|
||||
'perm.actionHint.budget_edit': 'Qui peut créer, modifier ou supprimer des éléments de budget',
|
||||
'perm.actionHint.packing_edit': 'Qui peut gérer les articles de bagages et les sacs',
|
||||
'perm.actionHint.collab_edit': 'Qui peut créer des notes, des sondages et envoyer des messages',
|
||||
'perm.actionHint.share_manage': 'Qui peut créer ou supprimer des liens de partage publics',
|
||||
// Undo
|
||||
'undo.button': 'Annuler',
|
||||
'undo.tooltip': 'Annuler : {action}',
|
||||
'undo.assignPlace': 'Lieu ajouté au jour',
|
||||
'undo.removeAssignment': 'Lieu retiré du jour',
|
||||
'undo.reorder': 'Lieux réorganisés',
|
||||
'undo.optimize': 'Itinéraire optimisé',
|
||||
'undo.deletePlace': 'Lieu supprimé',
|
||||
'undo.moveDay': 'Lieu déplacé vers un autre jour',
|
||||
'undo.lock': 'Verrouillage du lieu modifié',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
'undo.importGoogleList': 'Import Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Notifications',
|
||||
'notifications.markAllRead': 'Tout marquer comme lu',
|
||||
'notifications.deleteAll': 'Tout supprimer',
|
||||
'notifications.showAll': 'Voir toutes les notifications',
|
||||
'notifications.empty': 'Aucune notification',
|
||||
'notifications.emptyDescription': 'Vous êtes à jour !',
|
||||
'notifications.all': 'Toutes',
|
||||
'notifications.unreadOnly': 'Non lues',
|
||||
'notifications.markRead': 'Marquer comme lu',
|
||||
'notifications.markUnread': 'Marquer comme non lu',
|
||||
'notifications.delete': 'Supprimer',
|
||||
'notifications.system': 'Système',
|
||||
'memories.error.loadAlbums': 'Impossible de charger les albums',
|
||||
'memories.error.linkAlbum': 'Impossible de lier l\'album',
|
||||
'memories.error.unlinkAlbum': 'Impossible de dissocier l\'album',
|
||||
'memories.error.syncAlbum': 'Impossible de synchroniser l\'album',
|
||||
'memories.error.loadPhotos': 'Impossible de charger les photos',
|
||||
'memories.error.addPhotos': 'Impossible d\'ajouter les photos',
|
||||
'memories.error.removePhoto': 'Impossible de supprimer la photo',
|
||||
'memories.error.toggleSharing': 'Impossible de mettre à jour le partage',
|
||||
'undo.addPlace': 'Lieu ajouté',
|
||||
'undo.done': 'Annulé : {action}',
|
||||
'notifications.test.title': 'Notification test de {actor}',
|
||||
'notifications.test.text': 'Ceci est une simple notification de test.',
|
||||
'notifications.test.booleanTitle': '{actor} demande votre approbation',
|
||||
'notifications.test.booleanText': 'Notification de test booléenne.',
|
||||
'notifications.test.accept': 'Approuver',
|
||||
'notifications.test.decline': 'Refuser',
|
||||
'notifications.test.navigateTitle': 'Allez voir quelque chose',
|
||||
'notifications.test.navigateText': 'Notification de test de navigation.',
|
||||
'notifications.test.goThere': 'Y aller',
|
||||
'notifications.test.adminTitle': 'Diffusion admin',
|
||||
'notifications.test.adminText': '{actor} a envoyé une notification de test à tous les admins.',
|
||||
'notifications.test.tripTitle': '{actor} a publié dans votre voyage',
|
||||
'notifications.test.tripText': 'Notification de test pour le voyage "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Liste de bagages',
|
||||
'todo.subtab.todo': 'À faire',
|
||||
'todo.completed': 'terminé(s)',
|
||||
'todo.filter.all': 'Tout',
|
||||
'todo.filter.open': 'En cours',
|
||||
'todo.filter.done': 'Terminé',
|
||||
'todo.uncategorized': 'Sans catégorie',
|
||||
'todo.namePlaceholder': 'Nom de la tâche',
|
||||
'todo.descriptionPlaceholder': 'Description (facultative)',
|
||||
'todo.unassigned': 'Non assigné',
|
||||
'todo.noCategory': 'Aucune catégorie',
|
||||
'todo.hasDescription': 'Avec description',
|
||||
'todo.addItem': 'Ajouter une tâche...',
|
||||
'todo.newCategory': 'Nom de la catégorie',
|
||||
'todo.addCategory': 'Ajouter une catégorie',
|
||||
'todo.newItem': 'Nouvelle tâche',
|
||||
'todo.empty': 'Aucune tâche pour l\'instant. Ajoutez une tâche pour commencer !',
|
||||
'todo.filter.my': 'Mes tâches',
|
||||
'todo.filter.overdue': 'En retard',
|
||||
'todo.sidebar.tasks': 'Tâches',
|
||||
'todo.sidebar.categories': 'Catégories',
|
||||
'todo.detail.title': 'Tâche',
|
||||
'todo.detail.description': 'Description',
|
||||
'todo.detail.category': 'Catégorie',
|
||||
'todo.detail.dueDate': 'Date d\'échéance',
|
||||
'todo.detail.assignedTo': 'Assigné à',
|
||||
'todo.detail.delete': 'Supprimer',
|
||||
'todo.detail.save': 'Enregistrer les modifications',
|
||||
'todo.detail.create': 'Créer la tâche',
|
||||
'todo.detail.priority': 'Priorité',
|
||||
'todo.detail.noPriority': 'Aucune',
|
||||
'todo.sortByPrio': 'Priorité',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'Nouvelle version disponible',
|
||||
'settings.notificationPreferences.noChannels': 'Aucun canal de notification n\'est configuré. Demandez à un administrateur de configurer les notifications par e-mail ou webhook.',
|
||||
'settings.webhookUrl.label': 'URL du webhook',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Entrez votre URL de webhook Discord, Slack ou personnalisée pour recevoir des notifications.',
|
||||
'settings.webhookUrl.save': 'Enregistrer',
|
||||
'settings.webhookUrl.saved': 'URL du webhook enregistrée',
|
||||
'settings.webhookUrl.test': 'Tester',
|
||||
'settings.webhookUrl.testSuccess': 'Webhook de test envoyé avec succès',
|
||||
'settings.webhookUrl.testFailed': 'Échec du webhook de test',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'Les notifications in-app sont toujours actives et ne peuvent pas être désactivées globalement.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Webhook admin',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Ce webhook est utilisé exclusivement pour les notifications admin (ex. alertes de version). Il est séparé des webhooks utilisateur et s\'active automatiquement si une URL est configurée.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'URL du webhook admin enregistrée',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de test envoyé avec succès',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Échec du webhook de test',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Le webhook admin s\'active automatiquement si une URL est configurée',
|
||||
'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.',
|
||||
'admin.tabs.notifications': 'Notifications',
|
||||
'notifications.versionAvailable.title': 'Mise à jour disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
|
||||
'notifications.versionAvailable.button': 'Voir les détails',
|
||||
'notif.test.title': '[Test] Notification',
|
||||
'notif.test.simple.text': 'Ceci est une simple notification de test.',
|
||||
'notif.test.boolean.text': 'Acceptez-vous cette notification de test ?',
|
||||
'notif.test.navigate.text': 'Cliquez ci-dessous pour accéder au tableau de bord.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Invitation au voyage',
|
||||
'notif.trip_invite.text': '{actor} vous a invité à {trip}',
|
||||
'notif.booking_change.title': 'Réservation mise à jour',
|
||||
'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}',
|
||||
'notif.trip_reminder.title': 'Rappel de voyage',
|
||||
'notif.trip_reminder.text': 'Votre voyage {trip} approche !',
|
||||
'notif.vacay_invite.title': 'Invitation Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances',
|
||||
'notif.photos_shared.title': 'Photos partagées',
|
||||
'notif.photos_shared.text': '{actor} a partagé {count} photo(s) dans {trip}',
|
||||
'notif.collab_message.title': 'Nouveau message',
|
||||
'notif.collab_message.text': '{actor} a envoyé un message dans {trip}',
|
||||
'notif.packing_tagged.title': 'Affectation bagages',
|
||||
'notif.packing_tagged.text': '{actor} vous a assigné à {category} dans {trip}',
|
||||
'notif.version_available.title': 'Nouvelle version disponible',
|
||||
'notif.version_available.text': 'TREK {version} est maintenant disponible',
|
||||
'notif.action.view_trip': 'Voir le voyage',
|
||||
'notif.action.view_collab': 'Voir les messages',
|
||||
'notif.action.view_packing': 'Voir les bagages',
|
||||
'notif.action.view_photos': 'Voir les photos',
|
||||
'notif.action.view_vacay': 'Voir Vacay',
|
||||
'notif.action.view_admin': 'Aller à l\'admin',
|
||||
'notif.action.view': 'Voir',
|
||||
'notif.action.accept': 'Accepter',
|
||||
'notif.action.decline': 'Refuser',
|
||||
'notif.generic.title': 'Notification',
|
||||
'notif.generic.text': 'Vous avez une nouvelle notification',
|
||||
'notif.dev.unknown_event.title': '[DEV] Événement inconnu',
|
||||
'notif.dev.unknown_event.text': 'Le type d\'événement "{event}" n\'est pas enregistré dans EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default fr
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.edit': 'Szerkesztés',
|
||||
'common.add': 'Hozzáadás',
|
||||
'common.loading': 'Betöltés...',
|
||||
'common.import': 'Importálás',
|
||||
'common.error': 'Hiba',
|
||||
'common.back': 'Vissza',
|
||||
'common.all': 'Összes',
|
||||
@@ -25,6 +26,14 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Jelszó',
|
||||
'common.saving': 'Mentés...',
|
||||
'common.saved': 'Mentve',
|
||||
'trips.reminder': 'Emlékeztető',
|
||||
'trips.reminderNone': 'Nincs',
|
||||
'trips.reminderDay': 'nap',
|
||||
'trips.reminderDays': 'nap',
|
||||
'trips.reminderCustom': 'Egyéni',
|
||||
'trips.reminderDaysBefore': 'nappal indulás előtt',
|
||||
'trips.reminderDisabledHint': 'Az utazási emlékeztetők ki vannak kapcsolva. Kapcsold be az Admin > Beállítások > Értesítések menüben.',
|
||||
'common.update': 'Frissítés',
|
||||
'common.change': 'Módosítás',
|
||||
'common.uploading': 'Feltöltés…',
|
||||
@@ -71,7 +80,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.sharedBy': 'Megosztotta: {name}',
|
||||
'dashboard.days': 'nap',
|
||||
'dashboard.places': 'hely',
|
||||
'dashboard.members': 'Útitársak',
|
||||
'dashboard.archive': 'Archiválás',
|
||||
'dashboard.copyTrip': 'Másolás',
|
||||
'dashboard.copySuffix': 'másolat',
|
||||
'dashboard.restore': 'Visszaállítás',
|
||||
'dashboard.archived': 'Archivált',
|
||||
'dashboard.status.ongoing': 'Folyamatban',
|
||||
@@ -90,6 +102,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.archiveError': 'Nem sikerült archiválni',
|
||||
'dashboard.toast.restored': 'Utazás visszaállítva',
|
||||
'dashboard.toast.restoreError': 'Nem sikerült visszaállítani',
|
||||
'dashboard.toast.copied': 'Utazás másolva!',
|
||||
'dashboard.toast.copyError': 'Nem sikerült másolni az utazást',
|
||||
'dashboard.confirm.delete': '"{title}" utazás törlése? Minden hely és terv véglegesen törlődik.',
|
||||
'dashboard.editTrip': 'Utazás szerkesztése',
|
||||
'dashboard.createTrip': 'Új utazás létrehozása',
|
||||
@@ -99,6 +113,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'Miről szól ez az utazás?',
|
||||
'dashboard.startDate': 'Kezdő dátum',
|
||||
'dashboard.endDate': 'Záró dátum',
|
||||
'dashboard.dayCount': 'Napok száma',
|
||||
'dashboard.dayCountHint': 'Hány napot tervezzen, ha nincsenek utazási dátumok megadva.',
|
||||
'dashboard.noDateHint': 'Nincs dátum megadva — 7 alapértelmezett nap jön létre. Ezt bármikor módosíthatod.',
|
||||
'dashboard.coverImage': 'Borítókép',
|
||||
'dashboard.addCoverImage': 'Borítókép hozzáadása',
|
||||
@@ -113,6 +129,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Beállítások
|
||||
'settings.title': 'Beállítások',
|
||||
'settings.subtitle': 'Személyes beállítások konfigurálása',
|
||||
'settings.tabs.display': 'Megjelenés',
|
||||
'settings.tabs.map': 'Térkép',
|
||||
'settings.tabs.notifications': 'Értesítések',
|
||||
'settings.tabs.integrations': 'Integrációk',
|
||||
'settings.tabs.account': 'Fiók',
|
||||
'settings.tabs.about': 'Névjegy',
|
||||
'settings.map': 'Térkép',
|
||||
'settings.mapTemplate': 'Térkép sablon',
|
||||
'settings.mapTemplatePlaceholder.select': 'Sablon kiválasztása...',
|
||||
@@ -149,9 +171,46 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
|
||||
'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések',
|
||||
'settings.notifyWebhook': 'Webhook értesítések',
|
||||
'settings.notificationsDisabled': 'Az értesítések nincsenek beállítva. Kérje meg a rendszergazdát, hogy engedélyezze az e-mail vagy webhook értesítéseket.',
|
||||
'settings.notificationsActive': 'Aktív csatorna',
|
||||
'settings.notificationsManagedByAdmin': 'Az értesítési eseményeket az adminisztrátor konfigurálja.',
|
||||
'settings.on': 'Be',
|
||||
'settings.off': 'Ki',
|
||||
'settings.mcp.title': 'MCP konfiguráció',
|
||||
'settings.mcp.endpoint': 'MCP végpont',
|
||||
'settings.mcp.clientConfig': 'Kliens konfiguráció',
|
||||
'settings.mcp.clientConfigHint': 'Cserélje ki a <your_token> részt egy API tokenre az alábbi listából. Az npx elérési útját szükség lehet módosítani a rendszeréhez (pl. C:\\PROGRA~1\\nodejs\\npx.cmd Windows-on).',
|
||||
'settings.mcp.copy': 'Másolás',
|
||||
'settings.mcp.copied': 'Másolva!',
|
||||
'settings.mcp.apiTokens': 'API tokenek',
|
||||
'settings.mcp.createToken': 'Új token létrehozása',
|
||||
'settings.mcp.noTokens': 'Még nincsenek tokenek. Hozzon létre egyet MCP kliensek csatlakoztatásához.',
|
||||
'settings.mcp.tokenCreatedAt': 'Létrehozva',
|
||||
'settings.mcp.tokenUsedAt': 'Használva',
|
||||
'settings.mcp.deleteTokenTitle': 'Token törlése',
|
||||
'settings.mcp.deleteTokenMessage': 'Ez a token azonnal érvénytelenné válik. Minden MCP kliens, amely használja, elveszíti a hozzáférést.',
|
||||
'settings.mcp.modal.createTitle': 'API token létrehozása',
|
||||
'settings.mcp.modal.tokenName': 'Token neve',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'pl. Claude Desktop, Munkahelyi laptop',
|
||||
'settings.mcp.modal.creating': 'Létrehozás…',
|
||||
'settings.mcp.modal.create': 'Token létrehozása',
|
||||
'settings.mcp.modal.createdTitle': 'Token létrehozva',
|
||||
'settings.mcp.modal.createdWarning': 'Ez a token csak egyszer jelenik meg. Másolja és mentse el most — nem lehet visszaállítani.',
|
||||
'settings.mcp.modal.done': 'Kész',
|
||||
'settings.mcp.toast.created': 'Token létrehozva',
|
||||
'settings.mcp.toast.createError': 'Nem sikerült létrehozni a tokent',
|
||||
'settings.mcp.toast.deleted': 'Token törölve',
|
||||
'settings.mcp.toast.deleteError': 'Nem sikerült törölni a tokent',
|
||||
'settings.account': 'Fiók',
|
||||
'settings.about': 'Névjegy',
|
||||
'settings.about.reportBug': 'Hiba bejelentése',
|
||||
'settings.about.reportBugHint': 'Problémát találtál? Jelezd nekünk',
|
||||
'settings.about.featureRequest': 'Funkció javaslat',
|
||||
'settings.about.featureRequestHint': 'Javasolj egy új funkciót',
|
||||
'settings.about.wikiHint': 'Dokumentáció és útmutatók',
|
||||
'settings.about.description': 'A TREK egy saját szerveren üzemeltetett útitervező, amely segít az utazásaid megszervezésében az első ötlettől az utolsó emlékig. Napi tervezés, költségvetés, csomagolási listák, fotók és még sok más — minden egy helyen, a saját szervereden.',
|
||||
'settings.about.madeWith': 'Készítve',
|
||||
'settings.about.madeBy': 'Maurice és egy növekvő nyílt forráskódú közösség által.',
|
||||
'settings.username': 'Felhasználónév',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Szerepkör',
|
||||
@@ -165,7 +224,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.passwordRequired': 'Kérjük, add meg a jelenlegi és az új jelszót',
|
||||
'settings.currentPasswordRequired': 'A jelenlegi jelszó megadása kötelező',
|
||||
'settings.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
||||
'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt és számot',
|
||||
'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt, számot és speciális karaktert',
|
||||
'settings.passwordMismatch': 'A jelszavak nem egyeznek',
|
||||
'settings.passwordChanged': 'Jelszó sikeresen módosítva',
|
||||
'settings.deleteAccount': 'Törlés',
|
||||
@@ -187,6 +246,14 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.avatarError': 'Feltöltés sikertelen',
|
||||
'settings.mfa.title': 'Kétfaktoros hitelesítés (2FA)',
|
||||
'settings.mfa.description': 'Egy második lépést ad a bejelentkezéshez e-mail és jelszó használatakor. Használj hitelesítő alkalmazást (Google Authenticator, Authy stb.).',
|
||||
'settings.mfa.requiredByPolicy': 'A rendszergazda kétlépcsős hitelesítést ír elő. Állíts be hitelesítő alkalmazást lent, mielőtt továbblépnél.',
|
||||
'settings.mfa.backupTitle': 'Tartalék kódok',
|
||||
'settings.mfa.backupDescription': 'Használd ezeket az egyszer használatos kódokat, ha elveszíted a hozzáférést a hitelesítő alkalmazásodhoz.',
|
||||
'settings.mfa.backupWarning': 'Mentsd el ezeket most. Minden kód csak egyszer használható.',
|
||||
'settings.mfa.backupCopy': 'Kódok másolása',
|
||||
'settings.mfa.backupDownload': 'TXT letöltése',
|
||||
'settings.mfa.backupPrint': 'Nyomtatás / PDF',
|
||||
'settings.mfa.backupCopied': 'Tartalék kódok másolva',
|
||||
'settings.mfa.enabled': '2FA engedélyezve van a fiókodban.',
|
||||
'settings.mfa.disabled': '2FA nincs engedélyezve.',
|
||||
'settings.mfa.setup': 'Hitelesítő beállítása',
|
||||
@@ -201,9 +268,24 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve',
|
||||
'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva',
|
||||
'settings.mfa.demoBlocked': 'Demo módban nem érhető el',
|
||||
'settings.mustChangePassword': 'A folytatás előtt meg kell változtatnod a jelszavad. Kérjük, adj meg egy új jelszót alább.',
|
||||
'admin.notifications.title': 'Értesítések',
|
||||
'admin.notifications.hint': 'Válasszon értesítési csatornát. Egyszerre csak egy lehet aktív.',
|
||||
'admin.notifications.none': 'Kikapcsolva',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Értesítési események',
|
||||
'admin.notifications.eventsHint': 'Válaszd ki, mely események indítsanak értesítéseket minden felhasználó számára.',
|
||||
'admin.notifications.configureFirst': 'Először konfiguráld az SMTP vagy webhook beállításokat lent, majd engedélyezd az eseményeket.',
|
||||
'admin.notifications.save': 'Értesítési beállítások mentése',
|
||||
'admin.notifications.saved': 'Értesítési beállítások mentve',
|
||||
'admin.notifications.testWebhook': 'Teszt webhook küldése',
|
||||
'admin.notifications.testWebhookSuccess': 'Teszt webhook sikeresen elküldve',
|
||||
'admin.notifications.testWebhookFailed': 'Teszt webhook küldése sikertelen',
|
||||
'admin.smtp.title': 'E-mail és értesítések',
|
||||
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítésekhez. Opcionális: Webhook URL Discordhoz, Slackhez stb.',
|
||||
'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítések küldéséhez.',
|
||||
'admin.smtp.testButton': 'Teszt e-mail küldése',
|
||||
'admin.webhook.hint': 'Értesítések küldése külső webhookra (Discord, Slack stb.).',
|
||||
'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
|
||||
'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen',
|
||||
'dayplan.icsTooltip': 'Naptár exportálása (ICS)',
|
||||
@@ -263,6 +345,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.signIn': 'Bejelentkezés',
|
||||
'login.createAdmin': 'Admin fiók létrehozása',
|
||||
'login.createAdminHint': 'Hozd létre az első admin fiókot a TREK-hez.',
|
||||
'login.setNewPassword': 'Új jelszó beállítása',
|
||||
'login.setNewPasswordHint': 'A folytatás előtt meg kell változtatnia a jelszavát.',
|
||||
'login.createAccount': 'Fiók létrehozása',
|
||||
'login.createAccountHint': 'Új fiók regisztrálása.',
|
||||
'login.creating': 'Létrehozás…',
|
||||
@@ -289,7 +373,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Regisztráció
|
||||
'register.passwordMismatch': 'A jelszavak nem egyeznek',
|
||||
'register.passwordTooShort': 'A jelszónak legalább 6 karakter hosszúnak kell lennie',
|
||||
'register.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
||||
'register.failed': 'Regisztráció sikertelen',
|
||||
'register.getStarted': 'Kezdjük',
|
||||
'register.subtitle': 'Hozz létre egy fiókot, és kezdd el megtervezni álomutazásaidat.',
|
||||
@@ -364,6 +448,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.settings': 'Beállítások',
|
||||
'admin.allowRegistration': 'Regisztráció engedélyezése',
|
||||
'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat',
|
||||
'admin.requireMfa': 'Kétlépcsős hitelesítés (2FA) kötelezővé tétele',
|
||||
'admin.requireMfaHint': 'A 2FA nélküli felhasználóknak a Beállításokban kell befejezniük a beállítást az alkalmazás használata előtt.',
|
||||
'admin.apiKeys': 'API kulcsok',
|
||||
'admin.apiKeysHint': 'Opcionális. Bővített helyadatokat tesz lehetővé, például fotókat és időjárást.',
|
||||
'admin.mapsKey': 'Google Maps API kulcs',
|
||||
@@ -394,7 +480,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Csomagolási sablonok és poggyászkövetés
|
||||
'admin.bagTracking.title': 'Poggyászkövetés',
|
||||
'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
|
||||
'admin.tabs.config': 'Konfiguráció',
|
||||
'admin.tabs.config': 'Személyre szabás',
|
||||
'admin.tabs.templates': 'Csomagolási sablonok',
|
||||
'admin.packingTemplates.title': 'Csomagolási sablonok',
|
||||
'admin.packingTemplates.subtitle': 'Újrafelhasználható csomagolási listák létrehozása utazásaidhoz',
|
||||
@@ -418,8 +504,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.addons': 'Bővítmények',
|
||||
'admin.addons.title': 'Bővítmények',
|
||||
'admin.addons.subtitle': 'Funkciók engedélyezése vagy letiltása a TREK testreszabásához.',
|
||||
'admin.addons.catalog.packing.name': 'Csomagolás',
|
||||
'admin.addons.catalog.packing.description': 'Ellenőrzőlisták a poggyász előkészítéséhez minden utazáshoz',
|
||||
'admin.addons.catalog.packing.name': 'Listák',
|
||||
'admin.addons.catalog.packing.description': 'Csomagolási listák és teendők az utazásaidhoz',
|
||||
'admin.addons.catalog.budget.name': 'Költségvetés',
|
||||
'admin.addons.catalog.budget.description': 'Kiadások nyomon követése és az utazási költségvetés tervezése',
|
||||
'admin.addons.catalog.documents.name': 'Dokumentumok',
|
||||
@@ -432,14 +518,18 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.collab.description': 'Valós idejű jegyzetek, szavazások és csevegés az utazás tervezéséhez',
|
||||
'admin.addons.catalog.memories.name': 'Fotók (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Utazási fotók megosztása az Immich példányon keresztül',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol AI asszisztens integrációhoz',
|
||||
'admin.addons.subtitleBefore': 'Funkciók engedélyezése vagy letiltása a ',
|
||||
'admin.addons.subtitleAfter': ' testreszabásához.',
|
||||
'admin.addons.enabled': 'Engedélyezve',
|
||||
'admin.addons.disabled': 'Letiltva',
|
||||
'admin.addons.type.trip': 'Utazás',
|
||||
'admin.addons.type.global': 'Globális',
|
||||
'admin.addons.type.integration': 'Integráció',
|
||||
'admin.addons.tripHint': 'Fülként érhető el minden utazáson belül',
|
||||
'admin.addons.globalHint': 'Önálló szekcióként elérhető a fő navigációban',
|
||||
'admin.addons.integrationHint': 'Háttérszolgáltatások és API integrációk dedikált oldal nélkül',
|
||||
'admin.addons.toast.updated': 'Bővítmény frissítve',
|
||||
'admin.addons.toast.error': 'Nem sikerült frissíteni a bővítményt',
|
||||
'admin.addons.noAddons': 'Nincsenek elérhető bővítmények',
|
||||
@@ -455,7 +545,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.weather.requestsDesc': 'Ingyenes, nincs szükség API kulcsra',
|
||||
'admin.weather.locationHint': 'Az időjárás az adott nap első koordinátákkal rendelkező helye alapján készül. Ha nincs hely hozzárendelve a naphoz, a helylista bármelyik helye szolgál referenciául.',
|
||||
|
||||
'admin.tabs.audit': 'Auditnapló',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
|
||||
'admin.audit.subtitle': 'Biztonsági és adminisztrációs események (mentések, felhasználók, 2FA, beállítások).',
|
||||
'admin.audit.empty': 'Még nincsenek audit bejegyzések.',
|
||||
@@ -469,6 +559,22 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Részletek',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'MCP tokenek',
|
||||
'admin.mcpTokens.title': 'MCP tokenek',
|
||||
'admin.mcpTokens.subtitle': 'Összes felhasználó API tokeneinek kezelése',
|
||||
'admin.mcpTokens.owner': 'Tulajdonos',
|
||||
'admin.mcpTokens.tokenName': 'Token neve',
|
||||
'admin.mcpTokens.created': 'Létrehozva',
|
||||
'admin.mcpTokens.lastUsed': 'Utoljára használva',
|
||||
'admin.mcpTokens.never': 'Soha',
|
||||
'admin.mcpTokens.empty': 'Még nem hoztak létre MCP tokeneket',
|
||||
'admin.mcpTokens.deleteTitle': 'Token törlése',
|
||||
'admin.mcpTokens.deleteMessage': 'Ez a token azonnal érvénytelenítésre kerül. A felhasználó elveszíti az MCP hozzáférést ezen a tokenen keresztül.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token törölve',
|
||||
'admin.mcpTokens.deleteError': 'Nem sikerült törölni a tokent',
|
||||
'admin.mcpTokens.loadError': 'Nem sikerült betölteni a tokeneket',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.github.title': 'Frissítési előzmények',
|
||||
@@ -505,7 +611,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Szabadságnapok tervezése és kezelése',
|
||||
'vacay.settings': 'Beállítások',
|
||||
'vacay.year': 'Év',
|
||||
'vacay.addYear': 'Év hozzáadása',
|
||||
'vacay.addYear': 'Következő év hozzáadása',
|
||||
'vacay.addPrevYear': 'Előző év hozzáadása',
|
||||
'vacay.removeYear': 'Év eltávolítása',
|
||||
'vacay.removeYearConfirm': '{year} eltávolítása?',
|
||||
'vacay.removeYearHint': 'Az adott év összes szabadság-bejegyzése és céges szabadnapja véglegesen törlődik.',
|
||||
@@ -597,10 +704,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Eltávolítás',
|
||||
'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?',
|
||||
'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?',
|
||||
'atlas.confirmUnmarkRegion': 'Eltávolítod ezt a régiót a meglátogatottak listájáról?',
|
||||
'atlas.markVisited': 'Megjelölés meglátogatottként',
|
||||
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
|
||||
'atlas.markRegionVisitedHint': 'Régió hozzáadása a meglátogatottak listájához',
|
||||
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
|
||||
'atlas.addPoi': 'Hely hozzáadása',
|
||||
'atlas.searchCountry': 'Ország keresése...',
|
||||
'atlas.bucketNamePlaceholder': 'Név (ország, város, hely...)',
|
||||
'atlas.month': 'Hónap',
|
||||
'atlas.addToBucketHint': 'Mentés meglátogatni kívánt helyként',
|
||||
@@ -608,7 +718,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.statsTab': 'Statisztikák',
|
||||
'atlas.bucketTab': 'Bakancslista',
|
||||
'atlas.addBucket': 'Hozzáadás a bakancslistához',
|
||||
'atlas.bucketNamePlaceholder': 'Hely vagy úti cél...',
|
||||
'atlas.bucketNotesPlaceholder': 'Jegyzetek (opcionális)',
|
||||
'atlas.bucketEmpty': 'A bakancslistád üres',
|
||||
'atlas.bucketEmptyHint': 'Adj hozzá helyeket, ahová álmodsz eljutni',
|
||||
@@ -648,6 +757,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.reservationsShort': 'Foglalás',
|
||||
'trip.tabs.packing': 'Csomagolási lista',
|
||||
'trip.tabs.packingShort': 'Csomag',
|
||||
'trip.tabs.lists': 'Listák',
|
||||
'trip.tabs.listsShort': 'Listák',
|
||||
'trip.tabs.budget': 'Költségvetés',
|
||||
'trip.tabs.files': 'Fájlok',
|
||||
'trip.loading': 'Utazás betöltése...',
|
||||
@@ -663,6 +774,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Foglalás hozzáadva',
|
||||
'trip.toast.deleted': 'Törölve',
|
||||
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
|
||||
'trip.loadingPhotos': 'Helyek fotóinak betöltése...',
|
||||
|
||||
// Napi terv oldalsáv
|
||||
'dayplan.emptyDay': 'Nincs tervezett hely erre a napra',
|
||||
@@ -697,10 +809,15 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Helyek oldalsáv
|
||||
'places.addPlace': 'Hely/Tevékenység hozzáadása',
|
||||
'places.importGpx': 'GPX importálás',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} hely importálva GPX-ből',
|
||||
'places.urlResolved': 'Hely importálva URL-ből',
|
||||
'places.gpxError': 'GPX importálás sikertelen',
|
||||
'places.importGoogleList': 'Google Lista',
|
||||
'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.',
|
||||
'places.googleListImported': '{count} hely importalva a(z) "{list}" listabol',
|
||||
'places.googleListError': 'Google Maps lista importalasa sikertelen',
|
||||
'places.viewDetails': 'Részletek megtekintése',
|
||||
'places.assignToDay': 'Melyik naphoz adod?',
|
||||
'places.all': 'Összes',
|
||||
'places.unplanned': 'Nem tervezett',
|
||||
@@ -756,6 +873,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'Foglalás',
|
||||
'inspector.editRes': 'Foglalás szerkesztése',
|
||||
'inspector.participants': 'Résztvevők',
|
||||
'inspector.trackStats': 'Útvonal adatok',
|
||||
|
||||
// Foglalások
|
||||
'reservations.title': 'Foglalások',
|
||||
@@ -835,9 +953,36 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.linkAssignment': 'Összekapcsolás napi tervvel',
|
||||
'reservations.pickAssignment': 'Válassz hozzárendelést a tervedből...',
|
||||
'reservations.noAssignment': 'Nincs összekapcsolás (önálló)',
|
||||
'reservations.price': 'Ár',
|
||||
'reservations.budgetCategory': 'Költségvetési kategória',
|
||||
'reservations.budgetCategoryPlaceholder': 'pl. Közlekedés, Szállás',
|
||||
'reservations.budgetCategoryAuto': 'Automatikus (foglalás típusa alapján)',
|
||||
'reservations.budgetHint': 'Mentéskor automatikusan létrejön egy költségvetési tétel.',
|
||||
'reservations.departureDate': 'Indulás',
|
||||
'reservations.arrivalDate': 'Érkezés',
|
||||
'reservations.departureTime': 'Indulási idő',
|
||||
'reservations.arrivalTime': 'Érkezési idő',
|
||||
'reservations.pickupDate': 'Felvétel',
|
||||
'reservations.returnDate': 'Visszaadás',
|
||||
'reservations.pickupTime': 'Felvétel ideje',
|
||||
'reservations.returnTime': 'Visszaadás ideje',
|
||||
'reservations.endDate': 'Befejezés dátuma',
|
||||
'reservations.meta.departureTimezone': 'TZ indulás',
|
||||
'reservations.meta.arrivalTimezone': 'TZ érkezés',
|
||||
'reservations.span.departure': 'Indulás',
|
||||
'reservations.span.arrival': 'Érkezés',
|
||||
'reservations.span.inTransit': 'Úton',
|
||||
'reservations.span.pickup': 'Felvétel',
|
||||
'reservations.span.return': 'Visszaadás',
|
||||
'reservations.span.active': 'Aktív',
|
||||
'reservations.span.start': 'Kezdés',
|
||||
'reservations.span.end': 'Vége',
|
||||
'reservations.span.ongoing': 'Folyamatban',
|
||||
'reservations.validation.endBeforeStart': 'A befejezés dátuma/időpontja a kezdés utáni kell legyen',
|
||||
|
||||
// Költségvetés
|
||||
'budget.title': 'Költségvetés',
|
||||
'budget.exportCsv': 'CSV exportálás',
|
||||
'budget.emptyTitle': 'Még nincs költségvetés létrehozva',
|
||||
'budget.emptyText': 'Hozz létre kategóriákat és bejegyzéseket az utazási költségvetés tervezéséhez',
|
||||
'budget.emptyPlaceholder': 'Kategória neve...',
|
||||
@@ -852,6 +997,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.table.perDay': 'Naponta',
|
||||
'budget.table.perPersonDay': 'Fő / Nap',
|
||||
'budget.table.note': 'Megjegyzés',
|
||||
'budget.table.date': 'Dátum',
|
||||
'budget.newEntry': 'Új bejegyzés',
|
||||
'budget.defaultEntry': 'Új bejegyzés',
|
||||
'budget.defaultCategory': 'Új kategória',
|
||||
@@ -1246,6 +1392,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.chat.today': 'Ma',
|
||||
'collab.chat.yesterday': 'Tegnap',
|
||||
'collab.chat.deletedMessage': 'törölt egy üzenetet',
|
||||
'collab.chat.reply': 'Válasz',
|
||||
'collab.chat.loadMore': 'Korábbi üzenetek betöltése',
|
||||
'collab.chat.justNow': 'éppen most',
|
||||
'collab.chat.minutesAgo': '{n} perce',
|
||||
@@ -1314,12 +1461,19 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichUrl': 'Immich szerver URL',
|
||||
'memories.immichApiKey': 'API kulcs',
|
||||
'memories.testConnection': 'Kapcsolat tesztelése',
|
||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||
'memories.connected': 'Csatlakoztatva',
|
||||
'memories.disconnected': 'Nincs csatlakoztatva',
|
||||
'memories.connectionSuccess': 'Csatlakozva az Immichhez',
|
||||
'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez',
|
||||
'memories.saved': 'Immich beállítások mentve',
|
||||
'memories.addPhotos': 'Fotók hozzáadása',
|
||||
'memories.linkAlbum': 'Album csatolása',
|
||||
'memories.selectAlbum': 'Immich album kiválasztása',
|
||||
'memories.noAlbums': 'Nem található album',
|
||||
'memories.syncAlbum': 'Album szinkronizálása',
|
||||
'memories.unlinkAlbum': 'Leválasztás',
|
||||
'memories.photos': 'fotó',
|
||||
'memories.selectPhotos': 'Fotók kiválasztása az Immichből',
|
||||
'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
|
||||
'memories.selected': 'kijelölve',
|
||||
@@ -1335,6 +1489,204 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.confirmShareTitle': 'Megosztás az utazótársakkal?',
|
||||
'memories.confirmShareHint': '{count} fotó lesz látható az utazás összes tagja számára. Később egyenként is priváttá teheted őket.',
|
||||
'memories.confirmShareButton': 'Fotók megosztása',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Jogosultságok',
|
||||
'perm.title': 'Jogosultsági beállítások',
|
||||
'perm.subtitle': 'Szabályozd, ki milyen műveleteket végezhet az alkalmazásban',
|
||||
'perm.saved': 'Jogosultsági beállítások mentve',
|
||||
'perm.resetDefaults': 'Alapértelmezések visszaállítása',
|
||||
'perm.customized': 'testreszabott',
|
||||
'perm.level.admin': 'Csak adminisztrátor',
|
||||
'perm.level.tripOwner': 'Utazás tulajdonosa',
|
||||
'perm.level.tripMember': 'Utazás tagjai',
|
||||
'perm.level.everybody': 'Mindenki',
|
||||
'perm.cat.trip': 'Utazáskezelés',
|
||||
'perm.cat.members': 'Tagkezelés',
|
||||
'perm.cat.files': 'Fájlok',
|
||||
'perm.cat.content': 'Tartalom és menetrend',
|
||||
'perm.cat.extras': 'Költségvetés, csomagolás és együttműködés',
|
||||
'perm.action.trip_create': 'Utazások létrehozása',
|
||||
'perm.action.trip_edit': 'Utazás részleteinek szerkesztése',
|
||||
'perm.action.trip_delete': 'Utazások törlése',
|
||||
'perm.action.trip_archive': 'Utazások archiválása / visszaállítása',
|
||||
'perm.action.trip_cover_upload': 'Borítókép feltöltése',
|
||||
'perm.action.member_manage': 'Tagok hozzáadása / eltávolítása',
|
||||
'perm.action.file_upload': 'Fájlok feltöltése',
|
||||
'perm.action.file_edit': 'Fájl metaadatok szerkesztése',
|
||||
'perm.action.file_delete': 'Fájlok törlése',
|
||||
'perm.action.place_edit': 'Helyek hozzáadása / szerkesztése / törlése',
|
||||
'perm.action.day_edit': 'Napok, jegyzetek és hozzárendelések szerkesztése',
|
||||
'perm.action.reservation_edit': 'Foglalások kezelése',
|
||||
'perm.action.budget_edit': 'Költségvetés kezelése',
|
||||
'perm.action.packing_edit': 'Csomagolási listák kezelése',
|
||||
'perm.action.collab_edit': 'Együttműködés (jegyzetek, szavazások, chat)',
|
||||
'perm.action.share_manage': 'Megosztási linkek kezelése',
|
||||
'perm.actionHint.trip_create': 'Ki hozhat létre új utazásokat',
|
||||
'perm.actionHint.trip_edit': 'Ki módosíthatja az utazás nevét, dátumait, leírását és pénznemét',
|
||||
'perm.actionHint.trip_delete': 'Ki törölhet véglegesen egy utazást',
|
||||
'perm.actionHint.trip_archive': 'Ki archiválhat vagy állíthat vissza egy utazást',
|
||||
'perm.actionHint.trip_cover_upload': 'Ki tölthet fel vagy módosíthat borítóképet',
|
||||
'perm.actionHint.member_manage': 'Ki hívhat meg vagy távolíthat el utazás tagokat',
|
||||
'perm.actionHint.file_upload': 'Ki tölthet fel fájlokat egy utazáshoz',
|
||||
'perm.actionHint.file_edit': 'Ki szerkesztheti a fájlok leírásait és linkjeit',
|
||||
'perm.actionHint.file_delete': 'Ki helyezhet fájlokat a kukába vagy törölheti véglegesen',
|
||||
'perm.actionHint.place_edit': 'Ki adhat hozzá, szerkeszthet vagy törölhet helyeket',
|
||||
'perm.actionHint.day_edit': 'Ki szerkesztheti a napokat, napi jegyzeteket és hely-hozzárendeléseket',
|
||||
'perm.actionHint.reservation_edit': 'Ki hozhat létre, szerkeszthet vagy törölhet foglalásokat',
|
||||
'perm.actionHint.budget_edit': 'Ki hozhat létre, szerkeszthet vagy törölhet költségvetési tételeket',
|
||||
'perm.actionHint.packing_edit': 'Ki kezelheti a csomagolási tételeket és táskákat',
|
||||
'perm.actionHint.collab_edit': 'Ki hozhat létre jegyzeteket, szavazásokat és küldhet üzeneteket',
|
||||
'perm.actionHint.share_manage': 'Ki hozhat létre vagy törölhet nyilvános megosztási linkeket',
|
||||
// Undo
|
||||
'undo.button': 'Visszavonás',
|
||||
'undo.tooltip': 'Visszavonás: {action}',
|
||||
'undo.assignPlace': 'Hely naphoz rendelve',
|
||||
'undo.removeAssignment': 'Hely eltávolítva a napról',
|
||||
'undo.reorder': 'Helyek átrendezve',
|
||||
'undo.optimize': 'Útvonal optimalizálva',
|
||||
'undo.deletePlace': 'Hely törölve',
|
||||
'undo.moveDay': 'Hely áthelyezve másik napra',
|
||||
'undo.lock': 'Hely zárolása váltva',
|
||||
'undo.importGpx': 'GPX importálás',
|
||||
'undo.importGoogleList': 'Google Maps importálás',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Értesítések',
|
||||
'notifications.markAllRead': 'Összes olvasottnak jelölése',
|
||||
'notifications.deleteAll': 'Összes törlése',
|
||||
'notifications.showAll': 'Összes értesítés megtekintése',
|
||||
'notifications.empty': 'Nincsenek értesítések',
|
||||
'notifications.emptyDescription': 'Mindennel naprakész vagy!',
|
||||
'notifications.all': 'Összes',
|
||||
'notifications.unreadOnly': 'Olvasatlan',
|
||||
'notifications.markRead': 'Olvasottnak jelölés',
|
||||
'notifications.markUnread': 'Olvasatlannak jelölés',
|
||||
'notifications.delete': 'Törlés',
|
||||
'notifications.system': 'Rendszer',
|
||||
'memories.error.loadAlbums': 'Az albumok betöltése sikertelen',
|
||||
'memories.error.linkAlbum': 'Az album csatolása sikertelen',
|
||||
'memories.error.unlinkAlbum': 'Az album leválasztása sikertelen',
|
||||
'memories.error.syncAlbum': 'Az album szinkronizálása sikertelen',
|
||||
'memories.error.loadPhotos': 'A fotók betöltése sikertelen',
|
||||
'memories.error.addPhotos': 'A fotók hozzáadása sikertelen',
|
||||
'memories.error.removePhoto': 'A fotó eltávolítása sikertelen',
|
||||
'memories.error.toggleSharing': 'A megosztás frissítése sikertelen',
|
||||
'undo.addPlace': 'Hely hozzáadva',
|
||||
'undo.done': 'Visszavonva: {action}',
|
||||
'notifications.test.title': 'Teszt értesítés {actor} részéről',
|
||||
'notifications.test.text': 'Ez egy egyszerű teszt értesítés.',
|
||||
'notifications.test.booleanTitle': '{actor} jóváhagyásodat kéri',
|
||||
'notifications.test.booleanText': 'Teszt igen/nem értesítés.',
|
||||
'notifications.test.accept': 'Jóváhagyás',
|
||||
'notifications.test.decline': 'Elutasítás',
|
||||
'notifications.test.navigateTitle': 'Nézz meg valamit',
|
||||
'notifications.test.navigateText': 'Teszt navigációs értesítés.',
|
||||
'notifications.test.goThere': 'Odamegyek',
|
||||
'notifications.test.adminTitle': 'Adminisztrátor üzenet',
|
||||
'notifications.test.adminText': '{actor} teszt értesítést küldött az összes adminisztrátornak.',
|
||||
'notifications.test.tripTitle': '{actor} üzenetet küldött az utazásodba',
|
||||
'notifications.test.tripText': 'Teszt értesítés a(z) "{trip}" utazáshoz.',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Csomagolási lista',
|
||||
'todo.subtab.todo': 'Teendők',
|
||||
'todo.completed': 'kész',
|
||||
'todo.filter.all': 'Mind',
|
||||
'todo.filter.open': 'Nyitott',
|
||||
'todo.filter.done': 'Kész',
|
||||
'todo.uncategorized': 'Kategória nélküli',
|
||||
'todo.namePlaceholder': 'Feladat neve',
|
||||
'todo.descriptionPlaceholder': 'Leírás (opcionális)',
|
||||
'todo.unassigned': 'Nem hozzárendelt',
|
||||
'todo.noCategory': 'Nincs kategória',
|
||||
'todo.hasDescription': 'Van leírás',
|
||||
'todo.addItem': 'Új feladat hozzáadása...',
|
||||
'todo.newCategory': 'Kategória neve',
|
||||
'todo.addCategory': 'Kategória hozzáadása',
|
||||
'todo.newItem': 'Új feladat',
|
||||
'todo.empty': 'Még nincsenek feladatok. Adj hozzá egyet a kezdéshez!',
|
||||
'todo.filter.my': 'Saját feladataim',
|
||||
'todo.filter.overdue': 'Lejárt',
|
||||
'todo.sidebar.tasks': 'Feladatok',
|
||||
'todo.sidebar.categories': 'Kategóriák',
|
||||
'todo.detail.title': 'Feladat',
|
||||
'todo.detail.description': 'Leírás',
|
||||
'todo.detail.category': 'Kategória',
|
||||
'todo.detail.dueDate': 'Határidő',
|
||||
'todo.detail.assignedTo': 'Hozzárendelve',
|
||||
'todo.detail.delete': 'Törlés',
|
||||
'todo.detail.save': 'Módosítások mentése',
|
||||
'todo.detail.create': 'Feladat létrehozása',
|
||||
'todo.detail.priority': 'Prioritás',
|
||||
'todo.detail.noPriority': 'Nincs',
|
||||
'todo.sortByPrio': 'Prioritás',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'Új verzió elérhető',
|
||||
'settings.notificationPreferences.noChannels': 'Nincsenek értesítési csatornák beállítva. Kérd meg a rendszergazdát, hogy állítson be e-mail vagy webhook értesítéseket.',
|
||||
'settings.webhookUrl.label': 'Webhook URL',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Adja meg a Discord, Slack vagy egyéni webhook URL-jét az értesítések fogadásához.',
|
||||
'settings.webhookUrl.save': 'Mentés',
|
||||
'settings.webhookUrl.saved': 'Webhook URL mentve',
|
||||
'settings.webhookUrl.test': 'Teszt',
|
||||
'settings.webhookUrl.testSuccess': 'Teszt webhook sikeresen elküldve',
|
||||
'settings.webhookUrl.testFailed': 'Teszt webhook sikertelen',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'Az alkalmazáson belüli értesítések mindig aktívak, és globálisan nem kapcsolhatók ki.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Ez a webhook kizárólag admin értesítésekhez használatos (pl. verziófrissítési figyelmeztetések). Független a felhasználói webhookoktól, és automatikusan küld, ha URL van beállítva.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL mentve',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Teszt webhook sikeresen elküldve',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Teszt webhook sikertelen',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Az admin webhook automatikusan küld, ha URL van beállítva',
|
||||
'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
|
||||
'admin.tabs.notifications': 'Értesítések',
|
||||
'notifications.versionAvailable.title': 'Elérhető frissítés',
|
||||
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
|
||||
'notifications.versionAvailable.button': 'Részletek megtekintése',
|
||||
'notif.test.title': '[Teszt] Értesítés',
|
||||
'notif.test.simple.text': 'Ez egy egyszerű teszt értesítés.',
|
||||
'notif.test.boolean.text': 'Elfogadod ezt a teszt értesítést?',
|
||||
'notif.test.navigate.text': 'Kattints alább az irányítópultra navigáláshoz.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Utazásra meghívó',
|
||||
'notif.trip_invite.text': '{actor} meghívott a(z) {trip} utazásra',
|
||||
'notif.booking_change.title': 'Foglalás frissítve',
|
||||
'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban',
|
||||
'notif.trip_reminder.title': 'Utazás emlékeztető',
|
||||
'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion meghívó',
|
||||
'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához',
|
||||
'notif.photos_shared.title': 'Fotók megosztva',
|
||||
'notif.photos_shared.text': '{actor} {count} fotót osztott meg a(z) {trip} utazásban',
|
||||
'notif.collab_message.title': 'Új üzenet',
|
||||
'notif.collab_message.text': '{actor} üzenetet küldött a(z) {trip} utazásban',
|
||||
'notif.packing_tagged.title': 'Csomagolási feladat',
|
||||
'notif.packing_tagged.text': '{actor} hozzárendelte Önt a {category} kategóriához a(z) {trip} utazásban',
|
||||
'notif.version_available.title': 'Új verzió elérhető',
|
||||
'notif.version_available.text': 'A TREK {version} elérhető',
|
||||
'notif.action.view_trip': 'Utazás megtekintése',
|
||||
'notif.action.view_collab': 'Üzenetek megtekintése',
|
||||
'notif.action.view_packing': 'Csomagolás megtekintése',
|
||||
'notif.action.view_photos': 'Fotók megtekintése',
|
||||
'notif.action.view_vacay': 'Vacay megtekintése',
|
||||
'notif.action.view_admin': 'Admin megnyitása',
|
||||
'notif.action.view': 'Megtekintés',
|
||||
'notif.action.accept': 'Elfogadás',
|
||||
'notif.action.decline': 'Elutasítás',
|
||||
'notif.generic.title': 'Értesítés',
|
||||
'notif.generic.text': 'Új értesítésed érkezett',
|
||||
'notif.dev.unknown_event.title': '[DEV] Ismeretlen esemény',
|
||||
'notif.dev.unknown_event.text': 'A(z) "{event}" eseménytípus nincs regisztrálva az EVENT_NOTIFICATION_CONFIG-ban',
|
||||
}
|
||||
|
||||
export default hu
|
||||
|
||||
|
||||
+1692
-1340
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const nl: Record<string, string> = {
|
||||
'common.edit': 'Bewerken',
|
||||
'common.add': 'Toevoegen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importeren',
|
||||
'common.error': 'Fout',
|
||||
'common.back': 'Terug',
|
||||
'common.all': 'Alles',
|
||||
@@ -25,6 +26,14 @@ const nl: Record<string, string> = {
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Wachtwoord',
|
||||
'common.saving': 'Opslaan...',
|
||||
'common.saved': 'Opgeslagen',
|
||||
'trips.reminder': 'Herinnering',
|
||||
'trips.reminderNone': 'Geen',
|
||||
'trips.reminderDay': 'dag',
|
||||
'trips.reminderDays': 'dagen',
|
||||
'trips.reminderCustom': 'Aangepast',
|
||||
'trips.reminderDaysBefore': 'dagen voor vertrek',
|
||||
'trips.reminderDisabledHint': 'Reisherinneringen zijn uitgeschakeld. Schakel ze in via Admin > Instellingen > Meldingen.',
|
||||
'common.update': 'Bijwerken',
|
||||
'common.change': 'Wijzigen',
|
||||
'common.uploading': 'Uploaden…',
|
||||
@@ -71,7 +80,10 @@ const nl: Record<string, string> = {
|
||||
'dashboard.sharedBy': 'Gedeeld door {name}',
|
||||
'dashboard.days': 'Dagen',
|
||||
'dashboard.places': 'Plaatsen',
|
||||
'dashboard.members': 'Reisgenoten',
|
||||
'dashboard.archive': 'Archiveren',
|
||||
'dashboard.copyTrip': 'Kopiëren',
|
||||
'dashboard.copySuffix': 'kopie',
|
||||
'dashboard.restore': 'Herstellen',
|
||||
'dashboard.archived': 'Gearchiveerd',
|
||||
'dashboard.status.ongoing': 'Lopend',
|
||||
@@ -90,6 +102,8 @@ const nl: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': 'Reis archiveren mislukt',
|
||||
'dashboard.toast.restored': 'Reis hersteld',
|
||||
'dashboard.toast.restoreError': 'Reis herstellen mislukt',
|
||||
'dashboard.toast.copied': 'Reis gekopieerd!',
|
||||
'dashboard.toast.copyError': 'Reis kopiëren mislukt',
|
||||
'dashboard.confirm.delete': 'Reis "{title}" verwijderen? Alle plaatsen en plannen worden permanent verwijderd.',
|
||||
'dashboard.editTrip': 'Reis bewerken',
|
||||
'dashboard.createTrip': 'Nieuwe reis aanmaken',
|
||||
@@ -99,6 +113,8 @@ const nl: Record<string, string> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'Waar gaat deze reis over?',
|
||||
'dashboard.startDate': 'Startdatum',
|
||||
'dashboard.endDate': 'Einddatum',
|
||||
'dashboard.dayCount': 'Aantal dagen',
|
||||
'dashboard.dayCountHint': 'Hoeveel dagen te plannen wanneer er geen reisdata zijn ingesteld.',
|
||||
'dashboard.noDateHint': 'Geen datum ingesteld — er worden standaard 7 dagen aangemaakt. Je kunt dit altijd wijzigen.',
|
||||
'dashboard.coverImage': 'Omslagafbeelding',
|
||||
'dashboard.addCoverImage': 'Omslagafbeelding toevoegen',
|
||||
@@ -113,6 +129,12 @@ const nl: Record<string, string> = {
|
||||
// Settings
|
||||
'settings.title': 'Instellingen',
|
||||
'settings.subtitle': 'Configureer je persoonlijke instellingen',
|
||||
'settings.tabs.display': 'Weergave',
|
||||
'settings.tabs.map': 'Kaart',
|
||||
'settings.tabs.notifications': 'Meldingen',
|
||||
'settings.tabs.integrations': 'Integraties',
|
||||
'settings.tabs.account': 'Account',
|
||||
'settings.tabs.about': 'Over',
|
||||
'settings.map': 'Kaart',
|
||||
'settings.mapTemplate': 'Kaartsjabloon',
|
||||
'settings.mapTemplatePlaceholder.select': 'Selecteer sjabloon...',
|
||||
@@ -149,9 +171,26 @@ const nl: Record<string, string> = {
|
||||
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
|
||||
'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
|
||||
'settings.notifyWebhook': 'Webhook-meldingen',
|
||||
'settings.notificationsDisabled': 'Meldingen zijn niet geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te schakelen.',
|
||||
'settings.notificationsActive': 'Actief kanaal',
|
||||
'settings.notificationsManagedByAdmin': 'Meldingsgebeurtenissen worden geconfigureerd door je beheerder.',
|
||||
'admin.notifications.title': 'Meldingen',
|
||||
'admin.notifications.hint': 'Kies een meldingskanaal. Er kan er slechts één tegelijk actief zijn.',
|
||||
'admin.notifications.none': 'Uitgeschakeld',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Meldingsgebeurtenissen',
|
||||
'admin.notifications.eventsHint': 'Kies welke gebeurtenissen meldingen activeren voor alle gebruikers.',
|
||||
'admin.notifications.configureFirst': 'Configureer eerst de SMTP- of webhook-instellingen hieronder en schakel dan de events in.',
|
||||
'admin.notifications.save': 'Meldingsinstellingen opslaan',
|
||||
'admin.notifications.saved': 'Meldingsinstellingen opgeslagen',
|
||||
'admin.notifications.testWebhook': 'Testwebhook verzenden',
|
||||
'admin.notifications.testWebhookSuccess': 'Testwebhook succesvol verzonden',
|
||||
'admin.notifications.testWebhookFailed': 'Testwebhook mislukt',
|
||||
'admin.smtp.title': 'E-mail en meldingen',
|
||||
'admin.smtp.hint': 'SMTP-configuratie voor e-mailmeldingen. Optioneel: Webhook-URL voor Discord, Slack, etc.',
|
||||
'admin.smtp.hint': 'SMTP-configuratie voor het verzenden van e-mailmeldingen.',
|
||||
'admin.smtp.testButton': 'Test-e-mail verzenden',
|
||||
'admin.webhook.hint': 'Meldingen verzenden naar een externe webhook (Discord, Slack, enz.).',
|
||||
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
|
||||
'admin.smtp.testFailed': 'Test-e-mail mislukt',
|
||||
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
|
||||
@@ -185,13 +224,48 @@ const nl: Record<string, string> = {
|
||||
'share.permCollab': 'Chat',
|
||||
'settings.on': 'Aan',
|
||||
'settings.off': 'Uit',
|
||||
'settings.mcp.title': 'MCP-configuratie',
|
||||
'settings.mcp.endpoint': 'MCP-eindpunt',
|
||||
'settings.mcp.clientConfig': 'Clientconfiguratie',
|
||||
'settings.mcp.clientConfigHint': 'Vervang <your_token> door een API-token uit de onderstaande lijst. Het pad naar npx moet mogelijk worden aangepast voor jouw systeem (bijv. C:\\PROGRA~1\\nodejs\\npx.cmd op Windows).',
|
||||
'settings.mcp.copy': 'Kopiëren',
|
||||
'settings.mcp.copied': 'Gekopieerd!',
|
||||
'settings.mcp.apiTokens': 'API-tokens',
|
||||
'settings.mcp.createToken': 'Nieuw token aanmaken',
|
||||
'settings.mcp.noTokens': 'Nog geen tokens. Maak er een aan om MCP-clients te verbinden.',
|
||||
'settings.mcp.tokenCreatedAt': 'Aangemaakt',
|
||||
'settings.mcp.tokenUsedAt': 'Gebruikt',
|
||||
'settings.mcp.deleteTokenTitle': 'Token verwijderen',
|
||||
'settings.mcp.deleteTokenMessage': 'Dit token werkt onmiddellijk niet meer. Elke MCP-client die het gebruikt verliest de toegang.',
|
||||
'settings.mcp.modal.createTitle': 'API-token aanmaken',
|
||||
'settings.mcp.modal.tokenName': 'Tokennaam',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'bijv. Claude Desktop, Werklaptop',
|
||||
'settings.mcp.modal.creating': 'Aanmaken…',
|
||||
'settings.mcp.modal.create': 'Token aanmaken',
|
||||
'settings.mcp.modal.createdTitle': 'Token aangemaakt',
|
||||
'settings.mcp.modal.createdWarning': 'Dit token wordt slechts één keer getoond. Kopieer en bewaar het nu — het kan niet worden hersteld.',
|
||||
'settings.mcp.modal.done': 'Klaar',
|
||||
'settings.mcp.toast.created': 'Token aangemaakt',
|
||||
'settings.mcp.toast.createError': 'Token aanmaken mislukt',
|
||||
'settings.mcp.toast.deleted': 'Token verwijderd',
|
||||
'settings.mcp.toast.deleteError': 'Token verwijderen mislukt',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'Over',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
'settings.about.reportBugHint': 'Probleem gevonden? Laat het ons weten',
|
||||
'settings.about.featureRequest': 'Feature aanvragen',
|
||||
'settings.about.featureRequestHint': 'Stel een nieuwe functie voor',
|
||||
'settings.about.wikiHint': 'Documentatie en handleidingen',
|
||||
'settings.about.description': 'TREK is een zelf-gehoste reisplanner die je helpt je reizen te organiseren van het eerste idee tot de laatste herinnering. Dagplanning, budget, paklijsten, foto\'s en nog veel meer — alles op één plek, op je eigen server.',
|
||||
'settings.about.madeWith': 'Gemaakt met',
|
||||
'settings.about.madeBy': 'door Maurice en een groeiende open-source community.',
|
||||
'settings.username': 'Gebruikersnaam',
|
||||
'settings.email': 'E-mail',
|
||||
'settings.role': 'Rol',
|
||||
'settings.roleAdmin': 'Beheerder',
|
||||
'settings.oidcLinked': 'Gekoppeld met',
|
||||
'settings.changePassword': 'Wachtwoord wijzigen',
|
||||
'settings.mustChangePassword': 'U moet uw wachtwoord wijzigen voordat u kunt doorgaan. Stel hieronder een nieuw wachtwoord in.',
|
||||
'settings.currentPassword': 'Huidig wachtwoord',
|
||||
'settings.currentPasswordRequired': 'Huidig wachtwoord is verplicht',
|
||||
'settings.newPassword': 'Nieuw wachtwoord',
|
||||
@@ -200,7 +274,7 @@ const nl: Record<string, string> = {
|
||||
'settings.passwordRequired': 'Voer het huidige en nieuwe wachtwoord in',
|
||||
'settings.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
||||
'settings.passwordMismatch': 'Wachtwoorden komen niet overeen',
|
||||
'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters en een cijfer bevatten',
|
||||
'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters, een cijfer en een speciaal teken bevatten',
|
||||
'settings.passwordChanged': 'Wachtwoord succesvol gewijzigd',
|
||||
'settings.deleteAccount': 'Account verwijderen',
|
||||
'settings.deleteAccountTitle': 'Account verwijderen?',
|
||||
@@ -212,6 +286,14 @@ const nl: Record<string, string> = {
|
||||
'settings.saveProfile': 'Profiel opslaan',
|
||||
'settings.mfa.title': 'Tweefactorauthenticatie (2FA)',
|
||||
'settings.mfa.description': 'Voegt een tweede stap toe bij het inloggen. Gebruik een authenticator-app (Google Authenticator, Authy, etc.).',
|
||||
'settings.mfa.requiredByPolicy': 'Je beheerder vereist tweestapsverificatie. Stel hieronder een authenticator-app in voordat je verdergaat.',
|
||||
'settings.mfa.backupTitle': 'Back-upcodes',
|
||||
'settings.mfa.backupDescription': 'Gebruik deze eenmalige codes als je geen toegang meer hebt tot je authenticator-app.',
|
||||
'settings.mfa.backupWarning': 'Sla deze codes nu op. Elke code kan maar een keer worden gebruikt.',
|
||||
'settings.mfa.backupCopy': 'Codes kopiëren',
|
||||
'settings.mfa.backupDownload': 'TXT downloaden',
|
||||
'settings.mfa.backupPrint': 'Afdrukken / PDF',
|
||||
'settings.mfa.backupCopied': 'Back-upcodes gekopieerd',
|
||||
'settings.mfa.enabled': '2FA is ingeschakeld op je account.',
|
||||
'settings.mfa.disabled': '2FA is niet ingeschakeld.',
|
||||
'settings.mfa.setup': 'Authenticator instellen',
|
||||
@@ -263,6 +345,8 @@ const nl: Record<string, string> = {
|
||||
'login.signIn': 'Inloggen',
|
||||
'login.createAdmin': 'Beheerdersaccount aanmaken',
|
||||
'login.createAdminHint': 'Stel het eerste beheerdersaccount in voor TREK.',
|
||||
'login.setNewPassword': 'Nieuw wachtwoord instellen',
|
||||
'login.setNewPasswordHint': 'U moet uw wachtwoord wijzigen voordat u verder kunt gaan.',
|
||||
'login.createAccount': 'Account aanmaken',
|
||||
'login.createAccountHint': 'Registreer een nieuw account.',
|
||||
'login.creating': 'Aanmaken…',
|
||||
@@ -289,7 +373,7 @@ const nl: Record<string, string> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Wachtwoorden komen niet overeen',
|
||||
'register.passwordTooShort': 'Wachtwoord moet minimaal 6 tekens bevatten',
|
||||
'register.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
||||
'register.failed': 'Registratie mislukt',
|
||||
'register.getStarted': 'Aan de slag',
|
||||
'register.subtitle': 'Maak een account aan en begin met het plannen van je droomreizen.',
|
||||
@@ -315,7 +399,7 @@ const nl: Record<string, string> = {
|
||||
'admin.tabs.users': 'Gebruikers',
|
||||
'admin.tabs.categories': 'Categorieën',
|
||||
'admin.tabs.backup': 'Back-up',
|
||||
'admin.tabs.audit': 'Auditlog',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
'admin.stats.users': 'Gebruikers',
|
||||
'admin.stats.trips': 'Reizen',
|
||||
'admin.stats.places': 'Plaatsen',
|
||||
@@ -365,6 +449,8 @@ const nl: Record<string, string> = {
|
||||
'admin.tabs.settings': 'Instellingen',
|
||||
'admin.allowRegistration': 'Registratie toestaan',
|
||||
'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
|
||||
'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen',
|
||||
'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.',
|
||||
'admin.apiKeys': 'API-sleutels',
|
||||
'admin.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.',
|
||||
'admin.mapsKey': 'Google Maps API-sleutel',
|
||||
@@ -394,7 +480,7 @@ const nl: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': 'Bagagetracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
|
||||
'admin.tabs.config': 'Configuratie',
|
||||
'admin.tabs.config': 'Personalisatie',
|
||||
'admin.tabs.templates': 'Paksjablonen',
|
||||
'admin.packingTemplates.title': 'Paksjablonen',
|
||||
'admin.packingTemplates.subtitle': 'Herbruikbare paklijsten maken voor je reizen',
|
||||
@@ -418,10 +504,12 @@ const nl: Record<string, string> = {
|
||||
'admin.tabs.addons': 'Add-ons',
|
||||
'admin.addons.title': 'Add-ons',
|
||||
'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.',
|
||||
'admin.addons.catalog.memories.name': 'Herinneringen',
|
||||
'admin.addons.catalog.memories.description': 'Gedeelde fotoalbums voor elke reis',
|
||||
'admin.addons.catalog.packing.name': 'Inpakken',
|
||||
'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden',
|
||||
'admin.addons.catalog.memories.name': 'Foto\'s (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol voor AI-assistent integratie',
|
||||
'admin.addons.catalog.packing.name': 'Lijsten',
|
||||
'admin.addons.catalog.packing.description': 'Paklijsten en to-dotaken voor je reizen',
|
||||
'admin.addons.catalog.budget.name': 'Budget',
|
||||
'admin.addons.catalog.budget.description': 'Houd uitgaven bij en plan je reisbudget',
|
||||
'admin.addons.catalog.documents.name': 'Documenten',
|
||||
@@ -438,8 +526,10 @@ const nl: Record<string, string> = {
|
||||
'admin.addons.disabled': 'Uitgeschakeld',
|
||||
'admin.addons.type.trip': 'Reis',
|
||||
'admin.addons.type.global': 'Globaal',
|
||||
'admin.addons.type.integration': 'Integratie',
|
||||
'admin.addons.tripHint': 'Beschikbaar als tabblad binnen elke reis',
|
||||
'admin.addons.globalHint': 'Beschikbaar als zelfstandig onderdeel in de hoofdnavigatie',
|
||||
'admin.addons.integrationHint': 'Backenddiensten en API-integraties zonder eigen pagina',
|
||||
'admin.addons.toast.updated': 'Add-on bijgewerkt',
|
||||
'admin.addons.toast.error': 'Add-on bijwerken mislukt',
|
||||
'admin.addons.noAddons': 'Geen add-ons beschikbaar',
|
||||
@@ -455,6 +545,22 @@ const nl: Record<string, string> = {
|
||||
'admin.weather.requestsDesc': 'Gratis, geen API-sleutel vereist',
|
||||
'admin.weather.locationHint': 'Het weer is gebaseerd op de eerste plaats met coördinaten op elke dag. Als er geen plaats aan een dag is toegewezen, wordt een plaats uit de lijst als referentie gebruikt.',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'MCP-tokens',
|
||||
'admin.mcpTokens.title': 'MCP-tokens',
|
||||
'admin.mcpTokens.subtitle': 'API-tokens van alle gebruikers beheren',
|
||||
'admin.mcpTokens.owner': 'Eigenaar',
|
||||
'admin.mcpTokens.tokenName': 'Tokennaam',
|
||||
'admin.mcpTokens.created': 'Aangemaakt',
|
||||
'admin.mcpTokens.lastUsed': 'Laatst gebruikt',
|
||||
'admin.mcpTokens.never': 'Nooit',
|
||||
'admin.mcpTokens.empty': 'Er zijn nog geen MCP-tokens aangemaakt',
|
||||
'admin.mcpTokens.deleteTitle': 'Token verwijderen',
|
||||
'admin.mcpTokens.deleteMessage': 'Dit token wordt onmiddellijk ingetrokken. De gebruiker verliest MCP-toegang via dit token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token verwijderd',
|
||||
'admin.mcpTokens.deleteError': 'Token kon niet worden verwijderd',
|
||||
'admin.mcpTokens.loadError': 'Tokens konden niet worden geladen',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
@@ -504,7 +610,8 @@ const nl: Record<string, string> = {
|
||||
'vacay.subtitle': 'Plan en beheer vakantiedagen',
|
||||
'vacay.settings': 'Instellingen',
|
||||
'vacay.year': 'Jaar',
|
||||
'vacay.addYear': 'Jaar toevoegen',
|
||||
'vacay.addYear': 'Volgend jaar toevoegen',
|
||||
'vacay.addPrevYear': 'Vorig jaar toevoegen',
|
||||
'vacay.removeYear': 'Jaar verwijderen',
|
||||
'vacay.removeYearConfirm': '{year} verwijderen?',
|
||||
'vacay.removeYearHint': 'Alle vakantie-invoeren en bedrijfsvakanties voor dit jaar worden permanent verwijderd.',
|
||||
@@ -632,13 +739,14 @@ const nl: Record<string, string> = {
|
||||
'atlas.unmark': 'Verwijderen',
|
||||
'atlas.confirmMark': 'Dit land als bezocht markeren?',
|
||||
'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?',
|
||||
'atlas.confirmUnmarkRegion': 'Deze regio van je bezochte lijst verwijderen?',
|
||||
'atlas.markVisited': 'Markeren als bezocht',
|
||||
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
|
||||
'atlas.markRegionVisitedHint': 'Deze regio toevoegen aan je bezochte lijst',
|
||||
'atlas.addToBucket': 'Aan bucket list toevoegen',
|
||||
'atlas.addPoi': 'Plaats toevoegen',
|
||||
'atlas.bucketNamePlaceholder': 'Naam (land, stad, plek…)',
|
||||
'atlas.searchCountry': 'Zoek een land...',
|
||||
'atlas.month': 'Maand',
|
||||
'atlas.year': 'Jaar',
|
||||
'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken',
|
||||
'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?',
|
||||
|
||||
@@ -648,9 +756,12 @@ const nl: Record<string, string> = {
|
||||
'trip.tabs.reservationsShort': 'Boek',
|
||||
'trip.tabs.packing': 'Paklijst',
|
||||
'trip.tabs.packingShort': 'Inpakken',
|
||||
'trip.tabs.lists': 'Lijsten',
|
||||
'trip.tabs.listsShort': 'Lijsten',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Bestanden',
|
||||
'trip.loading': 'Reis laden...',
|
||||
'trip.loadingPhotos': 'Plaatsfoto laden...',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Plaatsen',
|
||||
'trip.toast.placeUpdated': 'Plaats bijgewerkt',
|
||||
@@ -697,9 +808,14 @@ const nl: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Plaats/activiteit toevoegen',
|
||||
'places.importGpx': 'GPX importeren',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
||||
'places.gpxError': 'GPX-import mislukt',
|
||||
'places.importGoogleList': 'Google Lijst',
|
||||
'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.',
|
||||
'places.googleListImported': '{count} plaatsen geimporteerd uit "{list}"',
|
||||
'places.googleListError': 'Google Maps lijst importeren mislukt',
|
||||
'places.viewDetails': 'Details bekijken',
|
||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||
'places.assignToDay': 'Aan welke dag toevoegen?',
|
||||
'places.all': 'Alle',
|
||||
@@ -756,6 +872,7 @@ const nl: Record<string, string> = {
|
||||
'inspector.addRes': 'Reservering',
|
||||
'inspector.editRes': 'Reservering bewerken',
|
||||
'inspector.participants': 'Deelnemers',
|
||||
'inspector.trackStats': 'Routegegevens',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Boekingen',
|
||||
@@ -835,9 +952,36 @@ const nl: Record<string, string> = {
|
||||
'reservations.linkAssignment': 'Koppelen aan dagtoewijzing',
|
||||
'reservations.pickAssignment': 'Selecteer een toewijzing uit je plan...',
|
||||
'reservations.noAssignment': 'Geen koppeling (zelfstandig)',
|
||||
'reservations.price': 'Prijs',
|
||||
'reservations.budgetCategory': 'Budgetcategorie',
|
||||
'reservations.budgetCategoryPlaceholder': 'bijv. Transport, Accommodatie',
|
||||
'reservations.budgetCategoryAuto': 'Automatisch (op basis van boekingstype)',
|
||||
'reservations.budgetHint': 'Er wordt automatisch een budgetpost aangemaakt bij het opslaan.',
|
||||
'reservations.departureDate': 'Vertrek',
|
||||
'reservations.arrivalDate': 'Aankomst',
|
||||
'reservations.departureTime': 'Vertrektijd',
|
||||
'reservations.arrivalTime': 'Aankomsttijd',
|
||||
'reservations.pickupDate': 'Ophalen',
|
||||
'reservations.returnDate': 'Inleveren',
|
||||
'reservations.pickupTime': 'Ophaaltijd',
|
||||
'reservations.returnTime': 'Inlevertijd',
|
||||
'reservations.endDate': 'Einddatum',
|
||||
'reservations.meta.departureTimezone': 'TZ vertrek',
|
||||
'reservations.meta.arrivalTimezone': 'TZ aankomst',
|
||||
'reservations.span.departure': 'Vertrek',
|
||||
'reservations.span.arrival': 'Aankomst',
|
||||
'reservations.span.inTransit': 'Onderweg',
|
||||
'reservations.span.pickup': 'Ophalen',
|
||||
'reservations.span.return': 'Inleveren',
|
||||
'reservations.span.active': 'Actief',
|
||||
'reservations.span.start': 'Start',
|
||||
'reservations.span.end': 'Einde',
|
||||
'reservations.span.ongoing': 'Lopend',
|
||||
'reservations.validation.endBeforeStart': 'Einddatum/-tijd moet na de startdatum/-tijd liggen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
'budget.exportCsv': 'CSV exporteren',
|
||||
'budget.emptyTitle': 'Nog geen budget aangemaakt',
|
||||
'budget.emptyText': 'Maak categorieën en invoeren aan om je reisbudget te plannen',
|
||||
'budget.emptyPlaceholder': 'Categorienaam invoeren...',
|
||||
@@ -852,6 +996,7 @@ const nl: Record<string, string> = {
|
||||
'budget.table.perDay': 'Per dag',
|
||||
'budget.table.perPersonDay': 'P. p. / dag',
|
||||
'budget.table.note': 'Notitie',
|
||||
'budget.table.date': 'Datum',
|
||||
'budget.newEntry': 'Nieuwe invoer',
|
||||
'budget.defaultEntry': 'Nieuwe invoer',
|
||||
'budget.defaultCategory': 'Nieuwe categorie',
|
||||
@@ -1245,6 +1390,7 @@ const nl: Record<string, string> = {
|
||||
'memories.immichUrl': 'Immich Server URL',
|
||||
'memories.immichApiKey': 'API-sleutel',
|
||||
'memories.testConnection': 'Verbinding testen',
|
||||
'memories.testFirst': 'Test eerst de verbinding',
|
||||
'memories.connected': 'Verbonden',
|
||||
'memories.disconnected': 'Niet verbonden',
|
||||
'memories.connectionSuccess': 'Verbonden met Immich',
|
||||
@@ -1254,6 +1400,12 @@ const nl: Record<string, string> = {
|
||||
'memories.newest': 'Nieuwste eerst',
|
||||
'memories.allLocations': 'Alle locaties',
|
||||
'memories.addPhotos': 'Foto\'s toevoegen',
|
||||
'memories.linkAlbum': 'Album koppelen',
|
||||
'memories.selectAlbum': 'Immich-album selecteren',
|
||||
'memories.noAlbums': 'Geen albums gevonden',
|
||||
'memories.syncAlbum': 'Album synchroniseren',
|
||||
'memories.unlinkAlbum': 'Ontkoppelen',
|
||||
'memories.photos': 'fotos',
|
||||
'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
|
||||
'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
|
||||
'memories.selected': 'geselecteerd',
|
||||
@@ -1270,7 +1422,7 @@ const nl: Record<string, string> = {
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notities',
|
||||
'collab.tabs.polls': 'Polls',
|
||||
'collab.tabs.polls': 'Peilingen',
|
||||
'collab.whatsNext.title': 'Wat komt er',
|
||||
'collab.whatsNext.today': 'Vandaag',
|
||||
'collab.whatsNext.tomorrow': 'Morgen',
|
||||
@@ -1285,6 +1437,7 @@ const nl: Record<string, string> = {
|
||||
'collab.chat.today': 'Vandaag',
|
||||
'collab.chat.yesterday': 'Gisteren',
|
||||
'collab.chat.deletedMessage': 'heeft een bericht verwijderd',
|
||||
'collab.chat.reply': 'Beantwoorden',
|
||||
'collab.chat.loadMore': 'Oudere berichten laden',
|
||||
'collab.chat.justNow': 'zojuist',
|
||||
'collab.chat.minutesAgo': '{n} min. geleden',
|
||||
@@ -1315,7 +1468,7 @@ const nl: Record<string, string> = {
|
||||
'collab.notes.attachFiles': 'Bestanden bijvoegen',
|
||||
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
|
||||
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
|
||||
'collab.polls.title': 'Polls',
|
||||
'collab.polls.title': 'Peilingen',
|
||||
'collab.polls.new': 'Nieuwe poll',
|
||||
'collab.polls.empty': 'Nog geen polls',
|
||||
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
||||
@@ -1335,6 +1488,204 @@ const nl: Record<string, string> = {
|
||||
'collab.polls.options': 'Opties',
|
||||
'collab.polls.delete': 'Verwijderen',
|
||||
'collab.polls.closedSection': 'Gesloten',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Rechten',
|
||||
'perm.title': 'Rechtinstellingen',
|
||||
'perm.subtitle': 'Bepaal wie welke acties mag uitvoeren in de applicatie',
|
||||
'perm.saved': 'Rechtinstellingen opgeslagen',
|
||||
'perm.resetDefaults': 'Standaardwaarden herstellen',
|
||||
'perm.customized': 'aangepast',
|
||||
'perm.level.admin': 'Alleen beheerder',
|
||||
'perm.level.tripOwner': 'Reiseigenaar',
|
||||
'perm.level.tripMember': 'Reisleden',
|
||||
'perm.level.everybody': 'Iedereen',
|
||||
'perm.cat.trip': 'Reisbeheer',
|
||||
'perm.cat.members': 'Ledenbeheer',
|
||||
'perm.cat.files': 'Bestanden',
|
||||
'perm.cat.content': 'Inhoud & planning',
|
||||
'perm.cat.extras': 'Budget, paklijsten & samenwerking',
|
||||
'perm.action.trip_create': 'Reizen aanmaken',
|
||||
'perm.action.trip_edit': 'Reisdetails bewerken',
|
||||
'perm.action.trip_delete': 'Reizen verwijderen',
|
||||
'perm.action.trip_archive': 'Reizen archiveren / dearchiveren',
|
||||
'perm.action.trip_cover_upload': 'Omslagfoto uploaden',
|
||||
'perm.action.member_manage': 'Leden toevoegen / verwijderen',
|
||||
'perm.action.file_upload': 'Bestanden uploaden',
|
||||
'perm.action.file_edit': 'Bestandsmetadata bewerken',
|
||||
'perm.action.file_delete': 'Bestanden verwijderen',
|
||||
'perm.action.place_edit': 'Plaatsen toevoegen / bewerken / verwijderen',
|
||||
'perm.action.day_edit': 'Dagen, notities & toewijzingen bewerken',
|
||||
'perm.action.reservation_edit': 'Reserveringen beheren',
|
||||
'perm.action.budget_edit': 'Budget beheren',
|
||||
'perm.action.packing_edit': 'Paklijsten beheren',
|
||||
'perm.action.collab_edit': 'Samenwerking (notities, polls, chat)',
|
||||
'perm.action.share_manage': 'Deellinks beheren',
|
||||
'perm.actionHint.trip_create': 'Wie kan nieuwe reizen aanmaken',
|
||||
'perm.actionHint.trip_edit': 'Wie kan reisnaam, data, beschrijving en valuta wijzigen',
|
||||
'perm.actionHint.trip_delete': 'Wie kan een reis permanent verwijderen',
|
||||
'perm.actionHint.trip_archive': 'Wie kan een reis archiveren of dearchiveren',
|
||||
'perm.actionHint.trip_cover_upload': 'Wie kan de omslagfoto uploaden of wijzigen',
|
||||
'perm.actionHint.member_manage': 'Wie kan reisleden uitnodigen of verwijderen',
|
||||
'perm.actionHint.file_upload': 'Wie kan bestanden uploaden naar een reis',
|
||||
'perm.actionHint.file_edit': 'Wie kan bestandsbeschrijvingen en links bewerken',
|
||||
'perm.actionHint.file_delete': 'Wie kan bestanden naar de prullenbak verplaatsen of permanent verwijderen',
|
||||
'perm.actionHint.place_edit': 'Wie kan plaatsen toevoegen, bewerken of verwijderen',
|
||||
'perm.actionHint.day_edit': 'Wie kan dagen, dagnotities en plaatstoewijzingen bewerken',
|
||||
'perm.actionHint.reservation_edit': 'Wie kan reserveringen aanmaken, bewerken of verwijderen',
|
||||
'perm.actionHint.budget_edit': 'Wie kan budgetposten aanmaken, bewerken of verwijderen',
|
||||
'perm.actionHint.packing_edit': 'Wie kan pakitems en tassen beheren',
|
||||
'perm.actionHint.collab_edit': 'Wie kan notities, polls aanmaken en berichten versturen',
|
||||
'perm.actionHint.share_manage': 'Wie kan openbare deellinks aanmaken of verwijderen',
|
||||
// Undo
|
||||
'undo.button': 'Ongedaan maken',
|
||||
'undo.tooltip': 'Ongedaan maken: {action}',
|
||||
'undo.assignPlace': 'Locatie aan dag toegewezen',
|
||||
'undo.removeAssignment': 'Locatie uit dag verwijderd',
|
||||
'undo.reorder': 'Locaties hergeordend',
|
||||
'undo.optimize': 'Route geoptimaliseerd',
|
||||
'undo.deletePlace': 'Locatie verwijderd',
|
||||
'undo.moveDay': 'Locatie naar andere dag verplaatst',
|
||||
'undo.lock': 'Vergrendeling locatie gewijzigd',
|
||||
'undo.importGpx': 'GPX-import',
|
||||
'undo.importGoogleList': 'Google Maps-import',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Meldingen',
|
||||
'notifications.markAllRead': 'Alles als gelezen markeren',
|
||||
'notifications.deleteAll': 'Alles verwijderen',
|
||||
'notifications.showAll': 'Alle meldingen weergeven',
|
||||
'notifications.empty': 'Geen meldingen',
|
||||
'notifications.emptyDescription': 'Je bent helemaal bijgewerkt!',
|
||||
'notifications.all': 'Alle',
|
||||
'notifications.unreadOnly': 'Ongelezen',
|
||||
'notifications.markRead': 'Markeren als gelezen',
|
||||
'notifications.markUnread': 'Markeren als ongelezen',
|
||||
'notifications.delete': 'Verwijderen',
|
||||
'notifications.system': 'Systeem',
|
||||
'memories.error.loadAlbums': 'Albums laden mislukt',
|
||||
'memories.error.linkAlbum': 'Album koppelen mislukt',
|
||||
'memories.error.unlinkAlbum': 'Album ontkoppelen mislukt',
|
||||
'memories.error.syncAlbum': 'Album synchroniseren mislukt',
|
||||
'memories.error.loadPhotos': 'Foto\'s laden mislukt',
|
||||
'memories.error.addPhotos': 'Foto\'s toevoegen mislukt',
|
||||
'memories.error.removePhoto': 'Foto verwijderen mislukt',
|
||||
'memories.error.toggleSharing': 'Delen bijwerken mislukt',
|
||||
'undo.addPlace': 'Locatie toegevoegd',
|
||||
'undo.done': 'Ongedaan gemaakt: {action}',
|
||||
'notifications.test.title': 'Testmelding van {actor}',
|
||||
'notifications.test.text': 'Dit is een eenvoudige testmelding.',
|
||||
'notifications.test.booleanTitle': '{actor} vraagt om uw goedkeuring',
|
||||
'notifications.test.booleanText': 'Booleaanse testmelding.',
|
||||
'notifications.test.accept': 'Goedkeuren',
|
||||
'notifications.test.decline': 'Afwijzen',
|
||||
'notifications.test.navigateTitle': 'Bekijk iets',
|
||||
'notifications.test.navigateText': 'Navigatie-testmelding.',
|
||||
'notifications.test.goThere': 'Ga erheen',
|
||||
'notifications.test.adminTitle': 'Admin-broadcast',
|
||||
'notifications.test.adminText': '{actor} heeft een testmelding naar alle admins gestuurd.',
|
||||
'notifications.test.tripTitle': '{actor} heeft gepost in uw reis',
|
||||
'notifications.test.tripText': 'Testmelding voor reis "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Paklijst',
|
||||
'todo.subtab.todo': 'Taken',
|
||||
'todo.completed': 'voltooid',
|
||||
'todo.filter.all': 'Alles',
|
||||
'todo.filter.open': 'Open',
|
||||
'todo.filter.done': 'Klaar',
|
||||
'todo.uncategorized': 'Zonder categorie',
|
||||
'todo.namePlaceholder': 'Taaknaam',
|
||||
'todo.descriptionPlaceholder': 'Beschrijving (optioneel)',
|
||||
'todo.unassigned': 'Niet toegewezen',
|
||||
'todo.noCategory': 'Geen categorie',
|
||||
'todo.hasDescription': 'Heeft beschrijving',
|
||||
'todo.addItem': 'Nieuwe taak toevoegen...',
|
||||
'todo.newCategory': 'Categorienaam',
|
||||
'todo.addCategory': 'Categorie toevoegen',
|
||||
'todo.newItem': 'Nieuwe taak',
|
||||
'todo.empty': 'Nog geen taken. Voeg een taak toe om te beginnen!',
|
||||
'todo.filter.my': 'Mijn taken',
|
||||
'todo.filter.overdue': 'Verlopen',
|
||||
'todo.sidebar.tasks': 'Taken',
|
||||
'todo.sidebar.categories': 'Categorieën',
|
||||
'todo.detail.title': 'Taak',
|
||||
'todo.detail.description': 'Beschrijving',
|
||||
'todo.detail.category': 'Categorie',
|
||||
'todo.detail.dueDate': 'Vervaldatum',
|
||||
'todo.detail.assignedTo': 'Toegewezen aan',
|
||||
'todo.detail.delete': 'Verwijderen',
|
||||
'todo.detail.save': 'Wijzigingen opslaan',
|
||||
'todo.detail.create': 'Taak aanmaken',
|
||||
'todo.detail.priority': 'Prioriteit',
|
||||
'todo.detail.noPriority': 'Geen',
|
||||
'todo.sortByPrio': 'Prioriteit',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'Nieuwe versie beschikbaar',
|
||||
'settings.notificationPreferences.noChannels': 'Er zijn geen meldingskanalen geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te stellen.',
|
||||
'settings.webhookUrl.label': 'Webhook-URL',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Voer je Discord-, Slack- of aangepaste webhook-URL in om meldingen te ontvangen.',
|
||||
'settings.webhookUrl.save': 'Opslaan',
|
||||
'settings.webhookUrl.saved': 'Webhook-URL opgeslagen',
|
||||
'settings.webhookUrl.test': 'Testen',
|
||||
'settings.webhookUrl.testSuccess': 'Test-webhook succesvol verzonden',
|
||||
'settings.webhookUrl.testFailed': 'Test-webhook mislukt',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'In-app-meldingen zijn altijd actief en kunnen niet globaal worden uitgeschakeld.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin-webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Deze webhook wordt uitsluitend gebruikt voor admin-meldingen (bijv. versie-updates). Hij staat los van gebruikerswebhooks en verstuurt automatisch als er een URL is ingesteld.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'Admin-webhook-URL opgeslagen',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-webhook succesvol verzonden',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld',
|
||||
'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
|
||||
'admin.tabs.notifications': 'Meldingen',
|
||||
'notifications.versionAvailable.title': 'Update beschikbaar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
|
||||
'notifications.versionAvailable.button': 'Details bekijken',
|
||||
'notif.test.title': '[Test] Melding',
|
||||
'notif.test.simple.text': 'Dit is een eenvoudige testmelding.',
|
||||
'notif.test.boolean.text': 'Accepteer je deze testmelding?',
|
||||
'notif.test.navigate.text': 'Klik hieronder om naar het dashboard te gaan.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Reisuitnodiging',
|
||||
'notif.trip_invite.text': '{actor} heeft je uitgenodigd voor {trip}',
|
||||
'notif.booking_change.title': 'Boeking bijgewerkt',
|
||||
'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}',
|
||||
'notif.trip_reminder.title': 'Reisherinnering',
|
||||
'notif.trip_reminder.text': 'Je reis {trip} komt eraan!',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging',
|
||||
'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren',
|
||||
'notif.photos_shared.title': 'Foto\'s gedeeld',
|
||||
'notif.photos_shared.text': '{actor} heeft {count} foto(\'s) gedeeld in {trip}',
|
||||
'notif.collab_message.title': 'Nieuw bericht',
|
||||
'notif.collab_message.text': '{actor} heeft een bericht gestuurd in {trip}',
|
||||
'notif.packing_tagged.title': 'Paklijsttaak',
|
||||
'notif.packing_tagged.text': '{actor} heeft je toegewezen aan {category} in {trip}',
|
||||
'notif.version_available.title': 'Nieuwe versie beschikbaar',
|
||||
'notif.version_available.text': 'TREK {version} is nu beschikbaar',
|
||||
'notif.action.view_trip': 'Reis bekijken',
|
||||
'notif.action.view_collab': 'Berichten bekijken',
|
||||
'notif.action.view_packing': 'Paklijst bekijken',
|
||||
'notif.action.view_photos': 'Foto\'s bekijken',
|
||||
'notif.action.view_vacay': 'Vacay bekijken',
|
||||
'notif.action.view_admin': 'Naar admin',
|
||||
'notif.action.view': 'Bekijken',
|
||||
'notif.action.accept': 'Accepteren',
|
||||
'notif.action.decline': 'Weigeren',
|
||||
'notif.generic.title': 'Melding',
|
||||
'notif.generic.text': 'Je hebt een nieuwe melding',
|
||||
'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis',
|
||||
'notif.dev.unknown_event.text': 'Gebeurtenistype "{event}" is niet geregistreerd in EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default nl
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const ru: Record<string, string> = {
|
||||
'common.edit': 'Редактировать',
|
||||
'common.add': 'Добавить',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.import': 'Импорт',
|
||||
'common.error': 'Ошибка',
|
||||
'common.back': 'Назад',
|
||||
'common.all': 'Все',
|
||||
@@ -25,6 +26,14 @@ const ru: Record<string, string> = {
|
||||
'common.email': 'Эл. почта',
|
||||
'common.password': 'Пароль',
|
||||
'common.saving': 'Сохранение...',
|
||||
'common.saved': 'Сохранено',
|
||||
'trips.reminder': 'Напоминание',
|
||||
'trips.reminderNone': 'Нет',
|
||||
'trips.reminderDay': 'день',
|
||||
'trips.reminderDays': 'дней',
|
||||
'trips.reminderCustom': 'Другое',
|
||||
'trips.reminderDaysBefore': 'дней до отъезда',
|
||||
'trips.reminderDisabledHint': 'Напоминания о поездках отключены. Включите их в Админ > Настройки > Уведомления.',
|
||||
'common.update': 'Обновить',
|
||||
'common.change': 'Изменить',
|
||||
'common.uploading': 'Загрузка…',
|
||||
@@ -71,7 +80,10 @@ const ru: Record<string, string> = {
|
||||
'dashboard.sharedBy': 'Поделился {name}',
|
||||
'dashboard.days': 'Дни',
|
||||
'dashboard.places': 'Места',
|
||||
'dashboard.members': 'Попутчики',
|
||||
'dashboard.archive': 'Архивировать',
|
||||
'dashboard.copyTrip': 'Копировать',
|
||||
'dashboard.copySuffix': 'копия',
|
||||
'dashboard.restore': 'Восстановить',
|
||||
'dashboard.archived': 'В архиве',
|
||||
'dashboard.status.ongoing': 'В процессе',
|
||||
@@ -90,6 +102,8 @@ const ru: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': 'Не удалось архивировать поездку',
|
||||
'dashboard.toast.restored': 'Поездка восстановлена',
|
||||
'dashboard.toast.restoreError': 'Не удалось восстановить поездку',
|
||||
'dashboard.toast.copied': 'Поездка скопирована!',
|
||||
'dashboard.toast.copyError': 'Не удалось скопировать поездку',
|
||||
'dashboard.confirm.delete': 'Удалить поездку «{title}»? Все места и планы будут безвозвратно удалены.',
|
||||
'dashboard.editTrip': 'Редактировать поездку',
|
||||
'dashboard.createTrip': 'Создать новую поездку',
|
||||
@@ -99,6 +113,8 @@ const ru: Record<string, string> = {
|
||||
'dashboard.tripDescriptionPlaceholder': 'О чём эта поездка?',
|
||||
'dashboard.startDate': 'Дата начала',
|
||||
'dashboard.endDate': 'Дата окончания',
|
||||
'dashboard.dayCount': 'Количество дней',
|
||||
'dashboard.dayCountHint': 'Сколько дней планировать, если даты поездки не указаны.',
|
||||
'dashboard.noDateHint': 'Дата не указана — будет создано 7 дней по умолчанию. Вы можете изменить это в любое время.',
|
||||
'dashboard.coverImage': 'Обложка',
|
||||
'dashboard.addCoverImage': 'Добавить обложку',
|
||||
@@ -113,6 +129,12 @@ const ru: Record<string, string> = {
|
||||
// Settings
|
||||
'settings.title': 'Настройки',
|
||||
'settings.subtitle': 'Настройте свои персональные параметры',
|
||||
'settings.tabs.display': 'Дисплей',
|
||||
'settings.tabs.map': 'Карта',
|
||||
'settings.tabs.notifications': 'Уведомления',
|
||||
'settings.tabs.integrations': 'Интеграции',
|
||||
'settings.tabs.account': 'Аккаунт',
|
||||
'settings.tabs.about': 'О приложении',
|
||||
'settings.map': 'Карта',
|
||||
'settings.mapTemplate': 'Шаблон карты',
|
||||
'settings.mapTemplatePlaceholder.select': 'Выберите шаблон...',
|
||||
@@ -149,9 +171,26 @@ const ru: Record<string, string> = {
|
||||
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
|
||||
'settings.notifyPackingTagged': 'Список вещей: назначения',
|
||||
'settings.notifyWebhook': 'Webhook-уведомления',
|
||||
'settings.notificationsDisabled': 'Уведомления не настроены. Попросите администратора включить уведомления по электронной почте или webhook.',
|
||||
'settings.notificationsActive': 'Активный канал',
|
||||
'settings.notificationsManagedByAdmin': 'События уведомлений настраиваются администратором.',
|
||||
'admin.notifications.title': 'Уведомления',
|
||||
'admin.notifications.hint': 'Выберите канал уведомлений. Одновременно может быть активен только один.',
|
||||
'admin.notifications.none': 'Отключено',
|
||||
'admin.notifications.email': 'Эл. почта (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'События уведомлений',
|
||||
'admin.notifications.eventsHint': 'Выберите, какие события вызывают уведомления для всех пользователей.',
|
||||
'admin.notifications.configureFirst': 'Сначала настройте SMTP или webhook ниже, затем включите события.',
|
||||
'admin.notifications.save': 'Сохранить настройки уведомлений',
|
||||
'admin.notifications.saved': 'Настройки уведомлений сохранены',
|
||||
'admin.notifications.testWebhook': 'Отправить тестовый вебхук',
|
||||
'admin.notifications.testWebhookSuccess': 'Тестовый вебхук успешно отправлен',
|
||||
'admin.notifications.testWebhookFailed': 'Ошибка отправки тестового вебхука',
|
||||
'admin.smtp.title': 'Почта и уведомления',
|
||||
'admin.smtp.hint': 'Настройка SMTP для уведомлений по почте. Необязательно: Webhook URL для Discord, Slack и т.д.',
|
||||
'admin.smtp.hint': 'Конфигурация SMTP для отправки уведомлений по электронной почте.',
|
||||
'admin.smtp.testButton': 'Отправить тестовое письмо',
|
||||
'admin.webhook.hint': 'Отправлять уведомления через внешний webhook (Discord, Slack и т.д.).',
|
||||
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
|
||||
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
|
||||
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
|
||||
@@ -185,13 +224,48 @@ const ru: Record<string, string> = {
|
||||
'share.permCollab': 'Чат',
|
||||
'settings.on': 'Вкл.',
|
||||
'settings.off': 'Выкл.',
|
||||
'settings.mcp.title': 'Настройка MCP',
|
||||
'settings.mcp.endpoint': 'MCP-эндпоинт',
|
||||
'settings.mcp.clientConfig': 'Конфигурация клиента',
|
||||
'settings.mcp.clientConfigHint': 'Замените <your_token> на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).',
|
||||
'settings.mcp.copy': 'Копировать',
|
||||
'settings.mcp.copied': 'Скопировано!',
|
||||
'settings.mcp.apiTokens': 'API-токены',
|
||||
'settings.mcp.createToken': 'Создать токен',
|
||||
'settings.mcp.noTokens': 'Токенов пока нет. Создайте один для подключения MCP-клиентов.',
|
||||
'settings.mcp.tokenCreatedAt': 'Создан',
|
||||
'settings.mcp.tokenUsedAt': 'Использован',
|
||||
'settings.mcp.deleteTokenTitle': 'Удалить токен',
|
||||
'settings.mcp.deleteTokenMessage': 'Этот токен перестанет работать немедленно. Любой MCP-клиент, использующий его, потеряет доступ.',
|
||||
'settings.mcp.modal.createTitle': 'Создать API-токен',
|
||||
'settings.mcp.modal.tokenName': 'Название токена',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': 'напр. Claude Desktop, Рабочий ноутбук',
|
||||
'settings.mcp.modal.creating': 'Создание…',
|
||||
'settings.mcp.modal.create': 'Создать токен',
|
||||
'settings.mcp.modal.createdTitle': 'Токен создан',
|
||||
'settings.mcp.modal.createdWarning': 'Этот токен будет показан только один раз. Скопируйте и сохраните его сейчас — восстановить его будет невозможно.',
|
||||
'settings.mcp.modal.done': 'Готово',
|
||||
'settings.mcp.toast.created': 'Токен создан',
|
||||
'settings.mcp.toast.createError': 'Не удалось создать токен',
|
||||
'settings.mcp.toast.deleted': 'Токен удалён',
|
||||
'settings.mcp.toast.deleteError': 'Не удалось удалить токен',
|
||||
'settings.account': 'Аккаунт',
|
||||
'settings.about': 'О приложении',
|
||||
'settings.about.reportBug': 'Сообщить об ошибке',
|
||||
'settings.about.reportBugHint': 'Нашли проблему? Сообщите нам',
|
||||
'settings.about.featureRequest': 'Предложить функцию',
|
||||
'settings.about.featureRequestHint': 'Предложите новую функцию',
|
||||
'settings.about.wikiHint': 'Документация и руководства',
|
||||
'settings.about.description': 'TREK — это самостоятельно размещаемый планировщик путешествий, который помогает организовать поездки от первой идеи до последнего воспоминания. Планирование по дням, бюджет, списки вещей, фото и многое другое — всё в одном месте, на вашем собственном сервере.',
|
||||
'settings.about.madeWith': 'Сделано с',
|
||||
'settings.about.madeBy': 'Морисом и растущим open-source сообществом.',
|
||||
'settings.username': 'Имя пользователя',
|
||||
'settings.email': 'Эл. почта',
|
||||
'settings.role': 'Роль',
|
||||
'settings.roleAdmin': 'Администратор',
|
||||
'settings.oidcLinked': 'Связан с',
|
||||
'settings.changePassword': 'Изменить пароль',
|
||||
'settings.mustChangePassword': 'Вы должны сменить пароль перед продолжением. Пожалуйста, установите новый пароль ниже.',
|
||||
'settings.currentPassword': 'Текущий пароль',
|
||||
'settings.currentPasswordRequired': 'Текущий пароль обязателен',
|
||||
'settings.newPassword': 'Новый пароль',
|
||||
@@ -200,7 +274,7 @@ const ru: Record<string, string> = {
|
||||
'settings.passwordRequired': 'Введите текущий и новый пароль',
|
||||
'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
|
||||
'settings.passwordMismatch': 'Пароли не совпадают',
|
||||
'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы и цифру',
|
||||
'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы, цифру и специальный символ',
|
||||
'settings.passwordChanged': 'Пароль успешно изменён',
|
||||
'settings.deleteAccount': 'Удалить аккаунт',
|
||||
'settings.deleteAccountTitle': 'Удалить ваш аккаунт?',
|
||||
@@ -212,6 +286,14 @@ const ru: Record<string, string> = {
|
||||
'settings.saveProfile': 'Сохранить профиль',
|
||||
'settings.mfa.title': 'Двухфакторная аутентификация (2FA)',
|
||||
'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).',
|
||||
'settings.mfa.requiredByPolicy': 'Администратор требует двухфакторную аутентификацию. Настройте приложение-аутентификатор ниже, прежде чем продолжить.',
|
||||
'settings.mfa.backupTitle': 'Резервные коды',
|
||||
'settings.mfa.backupDescription': 'Используйте эти одноразовые коды, если потеряете доступ к приложению-аутентификатору.',
|
||||
'settings.mfa.backupWarning': 'Сохраните их сейчас. Каждый код можно использовать только один раз.',
|
||||
'settings.mfa.backupCopy': 'Скопировать коды',
|
||||
'settings.mfa.backupDownload': 'Скачать TXT',
|
||||
'settings.mfa.backupPrint': 'Печать / PDF',
|
||||
'settings.mfa.backupCopied': 'Резервные коды скопированы',
|
||||
'settings.mfa.enabled': '2FA включена для вашего аккаунта.',
|
||||
'settings.mfa.disabled': '2FA не включена.',
|
||||
'settings.mfa.setup': 'Настроить аутентификатор',
|
||||
@@ -263,6 +345,8 @@ const ru: Record<string, string> = {
|
||||
'login.signIn': 'Войти',
|
||||
'login.createAdmin': 'Создать аккаунт администратора',
|
||||
'login.createAdminHint': 'Настройте первый аккаунт администратора для TREK.',
|
||||
'login.setNewPassword': 'Установить новый пароль',
|
||||
'login.setNewPasswordHint': 'Вы должны сменить пароль, прежде чем продолжить.',
|
||||
'login.createAccount': 'Создать аккаунт',
|
||||
'login.createAccountHint': 'Зарегистрируйте новый аккаунт.',
|
||||
'login.creating': 'Создание…',
|
||||
@@ -289,7 +373,7 @@ const ru: Record<string, string> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Пароли не совпадают',
|
||||
'register.passwordTooShort': 'Пароль должен содержать не менее 6 символов',
|
||||
'register.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
|
||||
'register.failed': 'Ошибка регистрации',
|
||||
'register.getStarted': 'Начать',
|
||||
'register.subtitle': 'Создайте аккаунт и начните планировать поездки мечты.',
|
||||
@@ -315,7 +399,7 @@ const ru: Record<string, string> = {
|
||||
'admin.tabs.users': 'Пользователи',
|
||||
'admin.tabs.categories': 'Категории',
|
||||
'admin.tabs.backup': 'Резервная копия',
|
||||
'admin.tabs.audit': 'Журнал аудита',
|
||||
'admin.tabs.audit': 'Аудит',
|
||||
'admin.stats.users': 'Пользователи',
|
||||
'admin.stats.trips': 'Поездки',
|
||||
'admin.stats.places': 'Места',
|
||||
@@ -365,6 +449,8 @@ const ru: Record<string, string> = {
|
||||
'admin.tabs.settings': 'Настройки',
|
||||
'admin.allowRegistration': 'Разрешить регистрацию',
|
||||
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
|
||||
'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)',
|
||||
'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.',
|
||||
'admin.apiKeys': 'API-ключи',
|
||||
'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.',
|
||||
'admin.mapsKey': 'API-ключ Google Maps',
|
||||
@@ -394,7 +480,7 @@ const ru: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': 'Отслеживание багажа',
|
||||
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
|
||||
'admin.tabs.config': 'Конфигурация',
|
||||
'admin.tabs.config': 'Персонализация',
|
||||
'admin.tabs.templates': 'Шаблоны упаковки',
|
||||
'admin.packingTemplates.title': 'Шаблоны упаковки',
|
||||
'admin.packingTemplates.subtitle': 'Создавайте многоразовые списки вещей для поездок',
|
||||
@@ -418,10 +504,12 @@ const ru: Record<string, string> = {
|
||||
'admin.tabs.addons': 'Дополнения',
|
||||
'admin.addons.title': 'Дополнения',
|
||||
'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.',
|
||||
'admin.addons.catalog.memories.name': 'Воспоминания',
|
||||
'admin.addons.catalog.memories.description': 'Общие фотоальбомы для каждой поездки',
|
||||
'admin.addons.catalog.packing.name': 'Сборы',
|
||||
'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке',
|
||||
'admin.addons.catalog.memories.name': 'Фото (Immich)',
|
||||
'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Протокол контекста модели для интеграции с ИИ-ассистентами',
|
||||
'admin.addons.catalog.packing.name': 'Списки',
|
||||
'admin.addons.catalog.packing.description': 'Списки вещей и задачи для ваших поездок',
|
||||
'admin.addons.catalog.budget.name': 'Бюджет',
|
||||
'admin.addons.catalog.budget.description': 'Отслеживайте расходы и планируйте бюджет поездки',
|
||||
'admin.addons.catalog.documents.name': 'Документы',
|
||||
@@ -438,8 +526,10 @@ const ru: Record<string, string> = {
|
||||
'admin.addons.disabled': 'Отключено',
|
||||
'admin.addons.type.trip': 'Поездка',
|
||||
'admin.addons.type.global': 'Глобально',
|
||||
'admin.addons.type.integration': 'Интеграция',
|
||||
'admin.addons.tripHint': 'Доступно как вкладка внутри каждой поездки',
|
||||
'admin.addons.globalHint': 'Доступно как отдельный раздел в основной навигации',
|
||||
'admin.addons.integrationHint': 'Фоновые сервисы и API-интеграции без отдельной страницы',
|
||||
'admin.addons.toast.updated': 'Дополнение обновлено',
|
||||
'admin.addons.toast.error': 'Не удалось обновить дополнение',
|
||||
'admin.addons.noAddons': 'Нет доступных дополнений',
|
||||
@@ -455,6 +545,22 @@ const ru: Record<string, string> = {
|
||||
'admin.weather.requestsDesc': 'Бесплатно, API-ключ не требуется',
|
||||
'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'MCP-токены',
|
||||
'admin.mcpTokens.title': 'MCP-токены',
|
||||
'admin.mcpTokens.subtitle': 'Управление API-токенами всех пользователей',
|
||||
'admin.mcpTokens.owner': 'Владелец',
|
||||
'admin.mcpTokens.tokenName': 'Название токена',
|
||||
'admin.mcpTokens.created': 'Создан',
|
||||
'admin.mcpTokens.lastUsed': 'Последнее использование',
|
||||
'admin.mcpTokens.never': 'Никогда',
|
||||
'admin.mcpTokens.empty': 'MCP-токены ещё не созданы',
|
||||
'admin.mcpTokens.deleteTitle': 'Удалить токен',
|
||||
'admin.mcpTokens.deleteMessage': 'Токен будет немедленно отозван. Пользователь потеряет доступ к MCP через этот токен.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Токен удалён',
|
||||
'admin.mcpTokens.deleteError': 'Не удалось удалить токен',
|
||||
'admin.mcpTokens.loadError': 'Не удалось загрузить токены',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
@@ -504,7 +610,8 @@ const ru: Record<string, string> = {
|
||||
'vacay.subtitle': 'Планируйте и управляйте днями отпуска',
|
||||
'vacay.settings': 'Настройки',
|
||||
'vacay.year': 'Год',
|
||||
'vacay.addYear': 'Добавить год',
|
||||
'vacay.addYear': 'Добавить следующий год',
|
||||
'vacay.addPrevYear': 'Добавить предыдущий год',
|
||||
'vacay.removeYear': 'Удалить год',
|
||||
'vacay.removeYearConfirm': 'Удалить {year}?',
|
||||
'vacay.removeYearHint': 'Все записи об отпуске и корпоративные выходные за этот год будут безвозвратно удалены.',
|
||||
@@ -632,13 +739,14 @@ const ru: Record<string, string> = {
|
||||
'atlas.unmark': 'Удалить',
|
||||
'atlas.confirmMark': 'Отметить эту страну как посещённую?',
|
||||
'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?',
|
||||
'atlas.confirmUnmarkRegion': 'Удалить этот регион из списка посещённых?',
|
||||
'atlas.markVisited': 'Отметить как посещённую',
|
||||
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
||||
'atlas.markRegionVisitedHint': 'Добавить этот регион в список посещённых',
|
||||
'atlas.addToBucket': 'В список желаний',
|
||||
'atlas.addPoi': 'Добавить место',
|
||||
'atlas.bucketNamePlaceholder': 'Название (страна, город, место…)',
|
||||
'atlas.searchCountry': 'Поиск страны...',
|
||||
'atlas.month': 'Месяц',
|
||||
'atlas.year': 'Год',
|
||||
'atlas.addToBucketHint': 'Сохранить как место для посещения',
|
||||
'atlas.bucketWhen': 'Когда вы планируете поехать?',
|
||||
|
||||
@@ -648,9 +756,12 @@ const ru: Record<string, string> = {
|
||||
'trip.tabs.reservationsShort': 'Брони',
|
||||
'trip.tabs.packing': 'Список вещей',
|
||||
'trip.tabs.packingShort': 'Вещи',
|
||||
'trip.tabs.lists': 'Списки',
|
||||
'trip.tabs.listsShort': 'Списки',
|
||||
'trip.tabs.budget': 'Бюджет',
|
||||
'trip.tabs.files': 'Файлы',
|
||||
'trip.loading': 'Загрузка поездки...',
|
||||
'trip.loadingPhotos': 'Загрузка фото мест...',
|
||||
'trip.mobilePlan': 'План',
|
||||
'trip.mobilePlaces': 'Места',
|
||||
'trip.toast.placeUpdated': 'Место обновлено',
|
||||
@@ -697,9 +808,14 @@ const ru: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Добавить место/активность',
|
||||
'places.importGpx': 'Импорт GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '{count} мест импортировано из GPX',
|
||||
'places.gpxError': 'Ошибка импорта GPX',
|
||||
'places.importGoogleList': 'Список Google',
|
||||
'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.',
|
||||
'places.googleListImported': '{count} мест импортировано из "{list}"',
|
||||
'places.googleListError': 'Не удалось импортировать список Google Maps',
|
||||
'places.viewDetails': 'Подробности',
|
||||
'places.urlResolved': 'Место импортировано из URL',
|
||||
'places.assignToDay': 'Добавить в какой день?',
|
||||
'places.all': 'Все',
|
||||
@@ -756,6 +872,7 @@ const ru: Record<string, string> = {
|
||||
'inspector.addRes': 'Бронирование',
|
||||
'inspector.editRes': 'Редактировать бронирование',
|
||||
'inspector.participants': 'Участники',
|
||||
'inspector.trackStats': 'Данные маршрута',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Бронирования',
|
||||
@@ -835,9 +952,36 @@ const ru: Record<string, string> = {
|
||||
'reservations.linkAssignment': 'Привязать к назначению дня',
|
||||
'reservations.pickAssignment': 'Выберите назначение из вашего плана...',
|
||||
'reservations.noAssignment': 'Без привязки (самостоятельное)',
|
||||
'reservations.price': 'Цена',
|
||||
'reservations.budgetCategory': 'Категория бюджета',
|
||||
'reservations.budgetCategoryPlaceholder': 'напр. Транспорт, Проживание',
|
||||
'reservations.budgetCategoryAuto': 'Авто (по типу бронирования)',
|
||||
'reservations.budgetHint': 'При сохранении будет автоматически создана запись бюджета.',
|
||||
'reservations.departureDate': 'Вылет',
|
||||
'reservations.arrivalDate': 'Прилёт',
|
||||
'reservations.departureTime': 'Время вылета',
|
||||
'reservations.arrivalTime': 'Время прилёта',
|
||||
'reservations.pickupDate': 'Получение',
|
||||
'reservations.returnDate': 'Возврат',
|
||||
'reservations.pickupTime': 'Время получения',
|
||||
'reservations.returnTime': 'Время возврата',
|
||||
'reservations.endDate': 'Дата окончания',
|
||||
'reservations.meta.departureTimezone': 'TZ вылета',
|
||||
'reservations.meta.arrivalTimezone': 'TZ прилёта',
|
||||
'reservations.span.departure': 'Вылет',
|
||||
'reservations.span.arrival': 'Прилёт',
|
||||
'reservations.span.inTransit': 'В пути',
|
||||
'reservations.span.pickup': 'Получение',
|
||||
'reservations.span.return': 'Возврат',
|
||||
'reservations.span.active': 'Активно',
|
||||
'reservations.span.start': 'Начало',
|
||||
'reservations.span.end': 'Конец',
|
||||
'reservations.span.ongoing': 'Продолжается',
|
||||
'reservations.validation.endBeforeStart': 'Дата/время окончания должны быть позже даты/времени начала',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Бюджет',
|
||||
'budget.exportCsv': 'Экспорт CSV',
|
||||
'budget.emptyTitle': 'Бюджет ещё не создан',
|
||||
'budget.emptyText': 'Создайте категории и записи для планирования бюджета поездки',
|
||||
'budget.emptyPlaceholder': 'Введите название категории...',
|
||||
@@ -852,6 +996,7 @@ const ru: Record<string, string> = {
|
||||
'budget.table.perDay': 'В день',
|
||||
'budget.table.perPersonDay': 'Чел. / день',
|
||||
'budget.table.note': 'Заметка',
|
||||
'budget.table.date': 'Дата',
|
||||
'budget.newEntry': 'Новая запись',
|
||||
'budget.defaultEntry': 'Новая запись',
|
||||
'budget.defaultCategory': 'Новая категория',
|
||||
@@ -1245,6 +1390,7 @@ const ru: Record<string, string> = {
|
||||
'memories.immichUrl': 'URL сервера Immich',
|
||||
'memories.immichApiKey': 'API-ключ',
|
||||
'memories.testConnection': 'Проверить подключение',
|
||||
'memories.testFirst': 'Сначала проверьте подключение',
|
||||
'memories.connected': 'Подключено',
|
||||
'memories.disconnected': 'Не подключено',
|
||||
'memories.connectionSuccess': 'Подключение к Immich установлено',
|
||||
@@ -1254,6 +1400,12 @@ const ru: Record<string, string> = {
|
||||
'memories.newest': 'Сначала новые',
|
||||
'memories.allLocations': 'Все места',
|
||||
'memories.addPhotos': 'Добавить фото',
|
||||
'memories.linkAlbum': 'Привязать альбом',
|
||||
'memories.selectAlbum': 'Выбрать альбом Immich',
|
||||
'memories.noAlbums': 'Альбомы не найдены',
|
||||
'memories.syncAlbum': 'Синхронизировать',
|
||||
'memories.unlinkAlbum': 'Отвязать',
|
||||
'memories.photos': 'фото',
|
||||
'memories.selectPhotos': 'Выбрать фото из Immich',
|
||||
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
|
||||
'memories.selected': 'выбрано',
|
||||
@@ -1285,6 +1437,7 @@ const ru: Record<string, string> = {
|
||||
'collab.chat.today': 'Сегодня',
|
||||
'collab.chat.yesterday': 'Вчера',
|
||||
'collab.chat.deletedMessage': 'удалил(а) сообщение',
|
||||
'collab.chat.reply': 'Ответить',
|
||||
'collab.chat.loadMore': 'Загрузить старые сообщения',
|
||||
'collab.chat.justNow': 'только что',
|
||||
'collab.chat.minutesAgo': '{n} мин. назад',
|
||||
@@ -1335,6 +1488,204 @@ const ru: Record<string, string> = {
|
||||
'collab.polls.options': 'Варианты',
|
||||
'collab.polls.delete': 'Удалить',
|
||||
'collab.polls.closedSection': 'Закрытые',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': 'Разрешения',
|
||||
'perm.title': 'Настройки разрешений',
|
||||
'perm.subtitle': 'Управляйте тем, кто может выполнять действия в приложении',
|
||||
'perm.saved': 'Настройки разрешений сохранены',
|
||||
'perm.resetDefaults': 'Сбросить по умолчанию',
|
||||
'perm.customized': 'изменено',
|
||||
'perm.level.admin': 'Только администратор',
|
||||
'perm.level.tripOwner': 'Владелец поездки',
|
||||
'perm.level.tripMember': 'Участники поездки',
|
||||
'perm.level.everybody': 'Все',
|
||||
'perm.cat.trip': 'Управление поездками',
|
||||
'perm.cat.members': 'Управление участниками',
|
||||
'perm.cat.files': 'Файлы',
|
||||
'perm.cat.content': 'Контент и расписание',
|
||||
'perm.cat.extras': 'Бюджет, сборы и совместная работа',
|
||||
'perm.action.trip_create': 'Создавать поездки',
|
||||
'perm.action.trip_edit': 'Редактировать детали поездки',
|
||||
'perm.action.trip_delete': 'Удалять поездки',
|
||||
'perm.action.trip_archive': 'Архивировать / разархивировать поездки',
|
||||
'perm.action.trip_cover_upload': 'Загружать обложку',
|
||||
'perm.action.member_manage': 'Добавлять / удалять участников',
|
||||
'perm.action.file_upload': 'Загружать файлы',
|
||||
'perm.action.file_edit': 'Редактировать метаданные файлов',
|
||||
'perm.action.file_delete': 'Удалять файлы',
|
||||
'perm.action.place_edit': 'Добавлять / редактировать / удалять места',
|
||||
'perm.action.day_edit': 'Редактировать дни, заметки и назначения',
|
||||
'perm.action.reservation_edit': 'Управлять бронированиями',
|
||||
'perm.action.budget_edit': 'Управлять бюджетом',
|
||||
'perm.action.packing_edit': 'Управлять списками вещей',
|
||||
'perm.action.collab_edit': 'Совместная работа (заметки, опросы, чат)',
|
||||
'perm.action.share_manage': 'Управлять ссылками для обмена',
|
||||
'perm.actionHint.trip_create': 'Кто может создавать новые поездки',
|
||||
'perm.actionHint.trip_edit': 'Кто может менять название, даты, описание и валюту поездки',
|
||||
'perm.actionHint.trip_delete': 'Кто может безвозвратно удалить поездку',
|
||||
'perm.actionHint.trip_archive': 'Кто может архивировать или разархивировать поездку',
|
||||
'perm.actionHint.trip_cover_upload': 'Кто может загружать или менять обложку',
|
||||
'perm.actionHint.member_manage': 'Кто может приглашать или удалять участников поездки',
|
||||
'perm.actionHint.file_upload': 'Кто может загружать файлы в поездку',
|
||||
'perm.actionHint.file_edit': 'Кто может редактировать описания и ссылки файлов',
|
||||
'perm.actionHint.file_delete': 'Кто может перемещать файлы в корзину или безвозвратно удалять',
|
||||
'perm.actionHint.place_edit': 'Кто может добавлять, редактировать или удалять места',
|
||||
'perm.actionHint.day_edit': 'Кто может редактировать дни, заметки к дням и назначения мест',
|
||||
'perm.actionHint.reservation_edit': 'Кто может создавать, редактировать или удалять бронирования',
|
||||
'perm.actionHint.budget_edit': 'Кто может создавать, редактировать или удалять статьи бюджета',
|
||||
'perm.actionHint.packing_edit': 'Кто может управлять вещами для сборов и сумками',
|
||||
'perm.actionHint.collab_edit': 'Кто может создавать заметки, опросы и отправлять сообщения',
|
||||
'perm.actionHint.share_manage': 'Кто может создавать или удалять публичные ссылки для обмена',
|
||||
// Undo
|
||||
'undo.button': 'Отменить',
|
||||
'undo.tooltip': 'Отменить: {action}',
|
||||
'undo.assignPlace': 'Место добавлено в день',
|
||||
'undo.removeAssignment': 'Место удалено из дня',
|
||||
'undo.reorder': 'Места переупорядочены',
|
||||
'undo.optimize': 'Маршрут оптимизирован',
|
||||
'undo.deletePlace': 'Место удалено',
|
||||
'undo.moveDay': 'Место перемещено в другой день',
|
||||
'undo.lock': 'Блокировка места изменена',
|
||||
'undo.importGpx': 'Импорт GPX',
|
||||
'undo.importGoogleList': 'Импорт из Google Maps',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': 'Уведомления',
|
||||
'notifications.markAllRead': 'Отметить все прочитанными',
|
||||
'notifications.deleteAll': 'Удалить все',
|
||||
'notifications.showAll': 'Показать все уведомления',
|
||||
'notifications.empty': 'Нет уведомлений',
|
||||
'notifications.emptyDescription': 'Вы в курсе всех событий!',
|
||||
'notifications.all': 'Все',
|
||||
'notifications.unreadOnly': 'Непрочитанные',
|
||||
'notifications.markRead': 'Отметить как прочитанное',
|
||||
'notifications.markUnread': 'Отметить как непрочитанное',
|
||||
'notifications.delete': 'Удалить',
|
||||
'notifications.system': 'Система',
|
||||
'memories.error.loadAlbums': 'Не удалось загрузить альбомы',
|
||||
'memories.error.linkAlbum': 'Не удалось привязать альбом',
|
||||
'memories.error.unlinkAlbum': 'Не удалось отвязать альбом',
|
||||
'memories.error.syncAlbum': 'Не удалось синхронизировать альбом',
|
||||
'memories.error.loadPhotos': 'Не удалось загрузить фотографии',
|
||||
'memories.error.addPhotos': 'Не удалось добавить фотографии',
|
||||
'memories.error.removePhoto': 'Не удалось удалить фотографию',
|
||||
'memories.error.toggleSharing': 'Не удалось обновить настройки доступа',
|
||||
'undo.addPlace': 'Место добавлено',
|
||||
'undo.done': 'Отменено: {action}',
|
||||
'notifications.test.title': 'Тестовое уведомление от {actor}',
|
||||
'notifications.test.text': 'Это простое тестовое уведомление.',
|
||||
'notifications.test.booleanTitle': '{actor} запрашивает подтверждение',
|
||||
'notifications.test.booleanText': 'Тестовое уведомление с выбором.',
|
||||
'notifications.test.accept': 'Подтвердить',
|
||||
'notifications.test.decline': 'Отклонить',
|
||||
'notifications.test.navigateTitle': 'Посмотрите на это',
|
||||
'notifications.test.navigateText': 'Тестовое уведомление с переходом.',
|
||||
'notifications.test.goThere': 'Перейти',
|
||||
'notifications.test.adminTitle': 'Рассылка администратора',
|
||||
'notifications.test.adminText': '{actor} отправил тестовое уведомление всем администраторам.',
|
||||
'notifications.test.tripTitle': '{actor} написал в вашей поездке',
|
||||
'notifications.test.tripText': 'Тестовое уведомление для поездки "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Список вещей',
|
||||
'todo.subtab.todo': 'Задачи',
|
||||
'todo.completed': 'выполнено',
|
||||
'todo.filter.all': 'Все',
|
||||
'todo.filter.open': 'Открытые',
|
||||
'todo.filter.done': 'Выполненные',
|
||||
'todo.uncategorized': 'Без категории',
|
||||
'todo.namePlaceholder': 'Название задачи',
|
||||
'todo.descriptionPlaceholder': 'Описание (необязательно)',
|
||||
'todo.unassigned': 'Не назначено',
|
||||
'todo.noCategory': 'Без категории',
|
||||
'todo.hasDescription': 'Есть описание',
|
||||
'todo.addItem': 'Добавить новую задачу...',
|
||||
'todo.newCategory': 'Название категории',
|
||||
'todo.addCategory': 'Добавить категорию',
|
||||
'todo.newItem': 'Новая задача',
|
||||
'todo.empty': 'Задач пока нет. Добавьте задачу, чтобы начать!',
|
||||
'todo.filter.my': 'Мои задачи',
|
||||
'todo.filter.overdue': 'Просроченные',
|
||||
'todo.sidebar.tasks': 'Задачи',
|
||||
'todo.sidebar.categories': 'Категории',
|
||||
'todo.detail.title': 'Задача',
|
||||
'todo.detail.description': 'Описание',
|
||||
'todo.detail.category': 'Категория',
|
||||
'todo.detail.dueDate': 'Срок выполнения',
|
||||
'todo.detail.assignedTo': 'Назначено',
|
||||
'todo.detail.delete': 'Удалить',
|
||||
'todo.detail.save': 'Сохранить изменения',
|
||||
'todo.detail.create': 'Создать задачу',
|
||||
'todo.detail.priority': 'Приоритет',
|
||||
'todo.detail.noPriority': 'Нет',
|
||||
'todo.sortByPrio': 'Приоритет',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': 'Доступна новая версия',
|
||||
'settings.notificationPreferences.noChannels': 'Каналы уведомлений не настроены. Попросите администратора настроить уведомления по электронной почте или через webhook.',
|
||||
'settings.webhookUrl.label': 'URL вебхука',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Введите URL вашего вебхука Discord, Slack или пользовательского для получения уведомлений.',
|
||||
'settings.webhookUrl.save': 'Сохранить',
|
||||
'settings.webhookUrl.saved': 'URL вебхука сохранён',
|
||||
'settings.webhookUrl.test': 'Тест',
|
||||
'settings.webhookUrl.testSuccess': 'Тестовый вебхук успешно отправлен',
|
||||
'settings.webhookUrl.testFailed': 'Ошибка тестового вебхука',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'Уведомления в приложении всегда активны и не могут быть отключены глобально.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Вебхук администратора',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Этот вебхук используется исключительно для уведомлений администратора (например, оповещения о версиях). Он независим от пользовательских вебхуков и отправляется автоматически при наличии URL.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'URL вебхука администратора сохранён',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
|
||||
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
|
||||
'admin.tabs.notifications': 'Уведомления',
|
||||
'notifications.versionAvailable.title': 'Доступно обновление',
|
||||
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
|
||||
'notifications.versionAvailable.button': 'Подробнее',
|
||||
'notif.test.title': '[Тест] Уведомление',
|
||||
'notif.test.simple.text': 'Это простое тестовое уведомление.',
|
||||
'notif.test.boolean.text': 'Вы принимаете это тестовое уведомление?',
|
||||
'notif.test.navigate.text': 'Нажмите ниже для перехода на панель управления.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Приглашение в поездку',
|
||||
'notif.trip_invite.text': '{actor} пригласил вас в {trip}',
|
||||
'notif.booking_change.title': 'Бронирование обновлено',
|
||||
'notif.booking_change.text': '{actor} обновил бронирование в {trip}',
|
||||
'notif.trip_reminder.title': 'Напоминание о поездке',
|
||||
'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!',
|
||||
'notif.vacay_invite.title': 'Приглашение Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска',
|
||||
'notif.photos_shared.title': 'Фото опубликованы',
|
||||
'notif.photos_shared.text': '{actor} поделился {count} фото в {trip}',
|
||||
'notif.collab_message.title': 'Новое сообщение',
|
||||
'notif.collab_message.text': '{actor} отправил сообщение в {trip}',
|
||||
'notif.packing_tagged.title': 'Задание для упаковки',
|
||||
'notif.packing_tagged.text': '{actor} назначил вас в {category} в {trip}',
|
||||
'notif.version_available.title': 'Доступна новая версия',
|
||||
'notif.version_available.text': 'TREK {version} теперь доступен',
|
||||
'notif.action.view_trip': 'Открыть поездку',
|
||||
'notif.action.view_collab': 'Открыть сообщения',
|
||||
'notif.action.view_packing': 'Открыть упаковку',
|
||||
'notif.action.view_photos': 'Открыть фото',
|
||||
'notif.action.view_vacay': 'Открыть Vacay',
|
||||
'notif.action.view_admin': 'Перейти в админ',
|
||||
'notif.action.view': 'Открыть',
|
||||
'notif.action.accept': 'Принять',
|
||||
'notif.action.decline': 'Отклонить',
|
||||
'notif.generic.title': 'Уведомление',
|
||||
'notif.generic.text': 'У вас новое уведомление',
|
||||
'notif.dev.unknown_event.title': '[DEV] Неизвестное событие',
|
||||
'notif.dev.unknown_event.text': 'Тип события "{event}" не зарегистрирован в EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default ru
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const zh: Record<string, string> = {
|
||||
'common.edit': '编辑',
|
||||
'common.add': '添加',
|
||||
'common.loading': '加载中...',
|
||||
'common.import': '导入',
|
||||
'common.error': '错误',
|
||||
'common.back': '返回',
|
||||
'common.all': '全部',
|
||||
@@ -25,6 +26,14 @@ const zh: Record<string, string> = {
|
||||
'common.email': '邮箱',
|
||||
'common.password': '密码',
|
||||
'common.saving': '保存中...',
|
||||
'common.saved': '已保存',
|
||||
'trips.reminder': '提醒',
|
||||
'trips.reminderNone': '无',
|
||||
'trips.reminderDay': '天',
|
||||
'trips.reminderDays': '天',
|
||||
'trips.reminderCustom': '自定义',
|
||||
'trips.reminderDaysBefore': '天前提醒',
|
||||
'trips.reminderDisabledHint': '旅行提醒已禁用。请在管理 > 设置 > 通知中启用。',
|
||||
'common.update': '更新',
|
||||
'common.change': '修改',
|
||||
'common.uploading': '上传中…',
|
||||
@@ -71,7 +80,10 @@ const zh: Record<string, string> = {
|
||||
'dashboard.sharedBy': '由 {name} 分享',
|
||||
'dashboard.days': '天',
|
||||
'dashboard.places': '地点',
|
||||
'dashboard.members': '旅伴',
|
||||
'dashboard.archive': '归档',
|
||||
'dashboard.copyTrip': '复制',
|
||||
'dashboard.copySuffix': '副本',
|
||||
'dashboard.restore': '恢复',
|
||||
'dashboard.archived': '已归档',
|
||||
'dashboard.status.ongoing': '进行中',
|
||||
@@ -90,6 +102,8 @@ const zh: Record<string, string> = {
|
||||
'dashboard.toast.archiveError': '归档旅行失败',
|
||||
'dashboard.toast.restored': '旅行已恢复',
|
||||
'dashboard.toast.restoreError': '恢复旅行失败',
|
||||
'dashboard.toast.copied': '旅行已复制!',
|
||||
'dashboard.toast.copyError': '复制旅行失败',
|
||||
'dashboard.confirm.delete': '删除旅行「{title}」?所有地点和计划将被永久删除。',
|
||||
'dashboard.editTrip': '编辑旅行',
|
||||
'dashboard.createTrip': '创建新旅行',
|
||||
@@ -99,6 +113,8 @@ const zh: Record<string, string> = {
|
||||
'dashboard.tripDescriptionPlaceholder': '这次旅行是关于什么的?',
|
||||
'dashboard.startDate': '开始日期',
|
||||
'dashboard.endDate': '结束日期',
|
||||
'dashboard.dayCount': '天数',
|
||||
'dashboard.dayCountHint': '未设置旅行日期时要规划的天数。',
|
||||
'dashboard.noDateHint': '未设置日期——将默认创建 7 天。你可以随时修改。',
|
||||
'dashboard.coverImage': '封面图片',
|
||||
'dashboard.addCoverImage': '添加封面图片',
|
||||
@@ -113,6 +129,12 @@ const zh: Record<string, string> = {
|
||||
// Settings
|
||||
'settings.title': '设置',
|
||||
'settings.subtitle': '配置你的个人设置',
|
||||
'settings.tabs.display': '显示',
|
||||
'settings.tabs.map': '地图',
|
||||
'settings.tabs.notifications': '通知',
|
||||
'settings.tabs.integrations': '集成',
|
||||
'settings.tabs.account': '账户',
|
||||
'settings.tabs.about': '关于',
|
||||
'settings.map': '地图',
|
||||
'settings.mapTemplate': '地图模板',
|
||||
'settings.mapTemplatePlaceholder.select': '选择模板...',
|
||||
@@ -149,9 +171,26 @@ const zh: Record<string, string> = {
|
||||
'settings.notifyCollabMessage': '聊天消息 (Collab)',
|
||||
'settings.notifyPackingTagged': '行李清单:分配',
|
||||
'settings.notifyWebhook': 'Webhook 通知',
|
||||
'settings.notificationsDisabled': '通知尚未配置。请联系管理员启用电子邮件或 Webhook 通知。',
|
||||
'settings.notificationsActive': '活跃频道',
|
||||
'settings.notificationsManagedByAdmin': '通知事件由管理员配置。',
|
||||
'admin.notifications.title': '通知',
|
||||
'admin.notifications.hint': '选择一个通知渠道。一次只能激活一个。',
|
||||
'admin.notifications.none': '已禁用',
|
||||
'admin.notifications.email': '电子邮件 (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': '通知事件',
|
||||
'admin.notifications.eventsHint': '选择哪些事件为所有用户触发通知。',
|
||||
'admin.notifications.configureFirst': '请先在下方配置 SMTP 或 Webhook,然后启用事件。',
|
||||
'admin.notifications.save': '保存通知设置',
|
||||
'admin.notifications.saved': '通知设置已保存',
|
||||
'admin.notifications.testWebhook': '发送测试 Webhook',
|
||||
'admin.notifications.testWebhookSuccess': '测试 Webhook 发送成功',
|
||||
'admin.notifications.testWebhookFailed': '测试 Webhook 发送失败',
|
||||
'admin.smtp.title': '邮件与通知',
|
||||
'admin.smtp.hint': '用于邮件通知的 SMTP 配置。可选:Discord、Slack 等的 Webhook URL。',
|
||||
'admin.smtp.hint': '用于发送电子邮件通知的 SMTP 配置。',
|
||||
'admin.smtp.testButton': '发送测试邮件',
|
||||
'admin.webhook.hint': '向外部 Webhook 发送通知(Discord、Slack 等)。',
|
||||
'admin.smtp.testSuccess': '测试邮件发送成功',
|
||||
'admin.smtp.testFailed': '测试邮件发送失败',
|
||||
'dayplan.icsTooltip': '导出日历 (ICS)',
|
||||
@@ -185,13 +224,48 @@ const zh: Record<string, string> = {
|
||||
'share.permCollab': '聊天',
|
||||
'settings.on': '开',
|
||||
'settings.off': '关',
|
||||
'settings.mcp.title': 'MCP 配置',
|
||||
'settings.mcp.endpoint': 'MCP 端点',
|
||||
'settings.mcp.clientConfig': '客户端配置',
|
||||
'settings.mcp.clientConfigHint': '将 <your_token> 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
|
||||
'settings.mcp.copy': '复制',
|
||||
'settings.mcp.copied': '已复制!',
|
||||
'settings.mcp.apiTokens': 'API 令牌',
|
||||
'settings.mcp.createToken': '创建新令牌',
|
||||
'settings.mcp.noTokens': '暂无令牌,请创建一个以连接 MCP 客户端。',
|
||||
'settings.mcp.tokenCreatedAt': '创建于',
|
||||
'settings.mcp.tokenUsedAt': '使用于',
|
||||
'settings.mcp.deleteTokenTitle': '删除令牌',
|
||||
'settings.mcp.deleteTokenMessage': '此令牌将立即失效,使用它的所有 MCP 客户端将失去访问权限。',
|
||||
'settings.mcp.modal.createTitle': '创建 API 令牌',
|
||||
'settings.mcp.modal.tokenName': '令牌名称',
|
||||
'settings.mcp.modal.tokenNamePlaceholder': '例如:Claude Desktop、工作电脑',
|
||||
'settings.mcp.modal.creating': '创建中…',
|
||||
'settings.mcp.modal.create': '创建令牌',
|
||||
'settings.mcp.modal.createdTitle': '令牌已创建',
|
||||
'settings.mcp.modal.createdWarning': '此令牌只会显示一次,请立即复制并妥善保存——无法找回。',
|
||||
'settings.mcp.modal.done': '完成',
|
||||
'settings.mcp.toast.created': '令牌已创建',
|
||||
'settings.mcp.toast.createError': '创建令牌失败',
|
||||
'settings.mcp.toast.deleted': '令牌已删除',
|
||||
'settings.mcp.toast.deleteError': '删除令牌失败',
|
||||
'settings.account': '账户',
|
||||
'settings.about': '关于',
|
||||
'settings.about.reportBug': '报告错误',
|
||||
'settings.about.reportBugHint': '发现问题?告诉我们',
|
||||
'settings.about.featureRequest': '功能建议',
|
||||
'settings.about.featureRequestHint': '建议一个新功能',
|
||||
'settings.about.wikiHint': '文档和指南',
|
||||
'settings.about.description': 'TREK 是一个自托管的旅行规划工具,帮助你从最初的想法到最后的回忆,全程组织你的旅行。日程规划、预算、行李清单、照片等——一切尽在一处,在你自己的服务器上。',
|
||||
'settings.about.madeWith': '用',
|
||||
'settings.about.madeBy': '由 Maurice 和不断壮大的开源社区打造。',
|
||||
'settings.username': '用户名',
|
||||
'settings.email': '邮箱',
|
||||
'settings.role': '角色',
|
||||
'settings.roleAdmin': '管理员',
|
||||
'settings.oidcLinked': '已关联',
|
||||
'settings.changePassword': '修改密码',
|
||||
'settings.mustChangePassword': '您必须更改密码才能继续。请在下方设置新密码。',
|
||||
'settings.currentPassword': '当前密码',
|
||||
'settings.currentPasswordRequired': '请输入当前密码',
|
||||
'settings.newPassword': '新密码',
|
||||
@@ -200,7 +274,7 @@ const zh: Record<string, string> = {
|
||||
'settings.passwordRequired': '请输入当前密码和新密码',
|
||||
'settings.passwordTooShort': '密码至少需要 8 个字符',
|
||||
'settings.passwordMismatch': '两次输入的密码不一致',
|
||||
'settings.passwordWeak': '密码必须包含大写字母、小写字母和数字',
|
||||
'settings.passwordWeak': '密码必须包含大写字母、小写字母、数字和特殊字符',
|
||||
'settings.passwordChanged': '密码修改成功',
|
||||
'settings.deleteAccount': '删除账户',
|
||||
'settings.deleteAccountTitle': '确定删除账户?',
|
||||
@@ -212,6 +286,14 @@ const zh: Record<string, string> = {
|
||||
'settings.saveProfile': '保存资料',
|
||||
'settings.mfa.title': '双因素认证 (2FA)',
|
||||
'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。',
|
||||
'settings.mfa.requiredByPolicy': '管理员要求双因素身份验证。请先完成下方的身份验证器设置后再继续。',
|
||||
'settings.mfa.backupTitle': '备用代码',
|
||||
'settings.mfa.backupDescription': '如果你无法使用身份验证器应用,可使用这些一次性备用代码登录。',
|
||||
'settings.mfa.backupWarning': '请立即保存这些代码。每个代码只能使用一次。',
|
||||
'settings.mfa.backupCopy': '复制代码',
|
||||
'settings.mfa.backupDownload': '下载 TXT',
|
||||
'settings.mfa.backupPrint': '打印 / PDF',
|
||||
'settings.mfa.backupCopied': '备用代码已复制',
|
||||
'settings.mfa.enabled': '您的账户已启用 2FA。',
|
||||
'settings.mfa.disabled': '2FA 未启用。',
|
||||
'settings.mfa.setup': '设置身份验证器',
|
||||
@@ -263,6 +345,8 @@ const zh: Record<string, string> = {
|
||||
'login.signIn': '登录',
|
||||
'login.createAdmin': '创建管理员账户',
|
||||
'login.createAdminHint': '为 TREK 设置第一个管理员账户。',
|
||||
'login.setNewPassword': '设置新密码',
|
||||
'login.setNewPasswordHint': '您必须更改密码才能继续。',
|
||||
'login.createAccount': '创建账户',
|
||||
'login.createAccountHint': '注册新账户。',
|
||||
'login.creating': '创建中…',
|
||||
@@ -289,7 +373,7 @@ const zh: Record<string, string> = {
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': '两次输入的密码不一致',
|
||||
'register.passwordTooShort': '密码至少需要 6 个字符',
|
||||
'register.passwordTooShort': '密码至少需要 8 个字符',
|
||||
'register.failed': '注册失败',
|
||||
'register.getStarted': '开始使用',
|
||||
'register.subtitle': '创建账户,开始规划你的梦想旅行。',
|
||||
@@ -315,7 +399,7 @@ const zh: Record<string, string> = {
|
||||
'admin.tabs.users': '用户',
|
||||
'admin.tabs.categories': '分类',
|
||||
'admin.tabs.backup': '备份',
|
||||
'admin.tabs.audit': '审计日志',
|
||||
'admin.tabs.audit': '审计',
|
||||
'admin.stats.users': '用户',
|
||||
'admin.stats.trips': '旅行',
|
||||
'admin.stats.places': '地点',
|
||||
@@ -365,6 +449,8 @@ const zh: Record<string, string> = {
|
||||
'admin.tabs.settings': '设置',
|
||||
'admin.allowRegistration': '允许注册',
|
||||
'admin.allowRegistrationHint': '新用户可以自行注册',
|
||||
'admin.requireMfa': '要求双因素身份验证(2FA)',
|
||||
'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。',
|
||||
'admin.apiKeys': 'API 密钥',
|
||||
'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。',
|
||||
'admin.mapsKey': 'Google Maps API 密钥',
|
||||
@@ -394,7 +480,7 @@ const zh: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': '行李追踪',
|
||||
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
|
||||
'admin.tabs.config': '配置',
|
||||
'admin.tabs.config': '个性化',
|
||||
'admin.tabs.templates': '打包模板',
|
||||
'admin.packingTemplates.title': '打包模板',
|
||||
'admin.packingTemplates.subtitle': '创建可复用的旅行打包清单',
|
||||
@@ -418,10 +504,12 @@ const zh: Record<string, string> = {
|
||||
'admin.tabs.addons': '扩展',
|
||||
'admin.addons.title': '扩展',
|
||||
'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。',
|
||||
'admin.addons.catalog.memories.name': '回忆',
|
||||
'admin.addons.catalog.memories.description': '每次旅行的共享相册',
|
||||
'admin.addons.catalog.packing.name': '行李',
|
||||
'admin.addons.catalog.packing.description': '每次旅行的行李准备清单',
|
||||
'admin.addons.catalog.memories.name': '照片 (Immich)',
|
||||
'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': '用于 AI 助手集成的模型上下文协议',
|
||||
'admin.addons.catalog.packing.name': '列表',
|
||||
'admin.addons.catalog.packing.description': '行程打包清单与待办任务',
|
||||
'admin.addons.catalog.budget.name': '预算',
|
||||
'admin.addons.catalog.budget.description': '跟踪支出并规划旅行预算',
|
||||
'admin.addons.catalog.documents.name': '文档',
|
||||
@@ -438,8 +526,10 @@ const zh: Record<string, string> = {
|
||||
'admin.addons.disabled': '已禁用',
|
||||
'admin.addons.type.trip': '旅行',
|
||||
'admin.addons.type.global': '全局',
|
||||
'admin.addons.type.integration': '集成',
|
||||
'admin.addons.tripHint': '在每次旅行中作为标签页显示',
|
||||
'admin.addons.globalHint': '在主导航中作为独立板块显示',
|
||||
'admin.addons.integrationHint': '后端服务和 API 集成,无专属页面',
|
||||
'admin.addons.toast.updated': '扩展已更新',
|
||||
'admin.addons.toast.error': '更新扩展失败',
|
||||
'admin.addons.noAddons': '暂无可用扩展',
|
||||
@@ -455,6 +545,22 @@ const zh: Record<string, string> = {
|
||||
'admin.weather.requestsDesc': '免费,无需 API 密钥',
|
||||
'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。',
|
||||
|
||||
// MCP Tokens
|
||||
'admin.tabs.mcpTokens': 'MCP 令牌',
|
||||
'admin.mcpTokens.title': 'MCP 令牌',
|
||||
'admin.mcpTokens.subtitle': '管理所有用户的 API 令牌',
|
||||
'admin.mcpTokens.owner': '所有者',
|
||||
'admin.mcpTokens.tokenName': '令牌名称',
|
||||
'admin.mcpTokens.created': '创建时间',
|
||||
'admin.mcpTokens.lastUsed': '最后使用',
|
||||
'admin.mcpTokens.never': '从未',
|
||||
'admin.mcpTokens.empty': '尚未创建任何 MCP 令牌',
|
||||
'admin.mcpTokens.deleteTitle': '删除令牌',
|
||||
'admin.mcpTokens.deleteMessage': '此令牌将立即被撤销。用户将失去通过此令牌的 MCP 访问权限。',
|
||||
'admin.mcpTokens.deleteSuccess': '令牌已删除',
|
||||
'admin.mcpTokens.deleteError': '删除令牌失败',
|
||||
'admin.mcpTokens.loadError': '加载令牌失败',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
|
||||
@@ -504,7 +610,8 @@ const zh: Record<string, string> = {
|
||||
'vacay.subtitle': '规划和管理假期',
|
||||
'vacay.settings': '设置',
|
||||
'vacay.year': '年份',
|
||||
'vacay.addYear': '添加年份',
|
||||
'vacay.addYear': '添加下一年',
|
||||
'vacay.addPrevYear': '添加上一年',
|
||||
'vacay.removeYear': '移除年份',
|
||||
'vacay.removeYearConfirm': '移除 {year}?',
|
||||
'vacay.removeYearHint': '该年度所有假期记录和公司假日将被永久删除。',
|
||||
@@ -632,13 +739,14 @@ const zh: Record<string, string> = {
|
||||
'atlas.unmark': '移除',
|
||||
'atlas.confirmMark': '将此国家标记为已访问?',
|
||||
'atlas.confirmUnmark': '从已访问列表中移除此国家?',
|
||||
'atlas.confirmUnmarkRegion': '从已访问列表中移除此地区?',
|
||||
'atlas.markVisited': '标记为已访问',
|
||||
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
||||
'atlas.markRegionVisitedHint': '将此地区添加到已访问列表',
|
||||
'atlas.addToBucket': '添加到心愿单',
|
||||
'atlas.addPoi': '添加地点',
|
||||
'atlas.bucketNamePlaceholder': '名称(国家、城市、地点…)',
|
||||
'atlas.searchCountry': '搜索国家...',
|
||||
'atlas.month': '月份',
|
||||
'atlas.year': '年份',
|
||||
'atlas.addToBucketHint': '保存为想去的地方',
|
||||
'atlas.bucketWhen': '你计划什么时候去?',
|
||||
|
||||
@@ -648,9 +756,12 @@ const zh: Record<string, string> = {
|
||||
'trip.tabs.reservationsShort': '预订',
|
||||
'trip.tabs.packing': '行李清单',
|
||||
'trip.tabs.packingShort': '行李',
|
||||
'trip.tabs.lists': '列表',
|
||||
'trip.tabs.listsShort': '列表',
|
||||
'trip.tabs.budget': '预算',
|
||||
'trip.tabs.files': '文件',
|
||||
'trip.loading': '加载旅行中...',
|
||||
'trip.loadingPhotos': '正在加载地点照片...',
|
||||
'trip.mobilePlan': '计划',
|
||||
'trip.mobilePlaces': '地点',
|
||||
'trip.toast.placeUpdated': '地点已更新',
|
||||
@@ -697,9 +808,14 @@ const zh: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': '添加地点/活动',
|
||||
'places.importGpx': '导入 GPX',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
||||
'places.gpxError': 'GPX 导入失败',
|
||||
'places.importGoogleList': 'Google 列表',
|
||||
'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
|
||||
'places.googleListImported': '已从"{list}"导入 {count} 个地点',
|
||||
'places.googleListError': 'Google Maps 列表导入失败',
|
||||
'places.viewDetails': '查看详情',
|
||||
'places.urlResolved': '已从 URL 导入地点',
|
||||
'places.assignToDay': '添加到哪一天?',
|
||||
'places.all': '全部',
|
||||
@@ -756,6 +872,7 @@ const zh: Record<string, string> = {
|
||||
'inspector.addRes': '预订',
|
||||
'inspector.editRes': '编辑预订',
|
||||
'inspector.participants': '参与者',
|
||||
'inspector.trackStats': '轨迹数据',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': '预订',
|
||||
@@ -835,9 +952,36 @@ const zh: Record<string, string> = {
|
||||
'reservations.linkAssignment': '关联日程分配',
|
||||
'reservations.pickAssignment': '从计划中选择一个分配...',
|
||||
'reservations.noAssignment': '无关联(独立)',
|
||||
'reservations.price': '价格',
|
||||
'reservations.budgetCategory': '预算类别',
|
||||
'reservations.budgetCategoryPlaceholder': '例:交通、住宿',
|
||||
'reservations.budgetCategoryAuto': '自动(按预订类型)',
|
||||
'reservations.budgetHint': '保存时将自动创建预算条目。',
|
||||
'reservations.departureDate': '出发',
|
||||
'reservations.arrivalDate': '到达',
|
||||
'reservations.departureTime': '出发时间',
|
||||
'reservations.arrivalTime': '到达时间',
|
||||
'reservations.pickupDate': '取车',
|
||||
'reservations.returnDate': '还车',
|
||||
'reservations.pickupTime': '取车时间',
|
||||
'reservations.returnTime': '还车时间',
|
||||
'reservations.endDate': '结束日期',
|
||||
'reservations.meta.departureTimezone': '出发时区',
|
||||
'reservations.meta.arrivalTimezone': '到达时区',
|
||||
'reservations.span.departure': '出发',
|
||||
'reservations.span.arrival': '到达',
|
||||
'reservations.span.inTransit': '途中',
|
||||
'reservations.span.pickup': '取车',
|
||||
'reservations.span.return': '还车',
|
||||
'reservations.span.active': '使用中',
|
||||
'reservations.span.start': '开始',
|
||||
'reservations.span.end': '结束',
|
||||
'reservations.span.ongoing': '进行中',
|
||||
'reservations.validation.endBeforeStart': '结束日期/时间必须晚于开始日期/时间',
|
||||
|
||||
// Budget
|
||||
'budget.title': '预算',
|
||||
'budget.exportCsv': '导出 CSV',
|
||||
'budget.emptyTitle': '尚未创建预算',
|
||||
'budget.emptyText': '创建分类和条目来规划旅行预算',
|
||||
'budget.emptyPlaceholder': '输入分类名称...',
|
||||
@@ -852,6 +996,7 @@ const zh: Record<string, string> = {
|
||||
'budget.table.perDay': '日均',
|
||||
'budget.table.perPersonDay': '人日均',
|
||||
'budget.table.note': '备注',
|
||||
'budget.table.date': '日期',
|
||||
'budget.newEntry': '新建条目',
|
||||
'budget.defaultEntry': '新建条目',
|
||||
'budget.defaultCategory': '新分类',
|
||||
@@ -1245,6 +1390,7 @@ const zh: Record<string, string> = {
|
||||
'memories.immichUrl': 'Immich 服务器地址',
|
||||
'memories.immichApiKey': 'API 密钥',
|
||||
'memories.testConnection': '测试连接',
|
||||
'memories.testFirst': '请先测试连接',
|
||||
'memories.connected': '已连接',
|
||||
'memories.disconnected': '未连接',
|
||||
'memories.connectionSuccess': '已连接到 Immich',
|
||||
@@ -1254,6 +1400,12 @@ const zh: Record<string, string> = {
|
||||
'memories.newest': '最新优先',
|
||||
'memories.allLocations': '所有地点',
|
||||
'memories.addPhotos': '添加照片',
|
||||
'memories.linkAlbum': '关联相册',
|
||||
'memories.selectAlbum': '选择 Immich 相册',
|
||||
'memories.noAlbums': '未找到相册',
|
||||
'memories.syncAlbum': '同步相册',
|
||||
'memories.unlinkAlbum': '取消关联',
|
||||
'memories.photos': '张照片',
|
||||
'memories.selectPhotos': '从 Immich 选择照片',
|
||||
'memories.selectHint': '点击照片以选择。',
|
||||
'memories.selected': '已选择',
|
||||
@@ -1285,6 +1437,7 @@ const zh: Record<string, string> = {
|
||||
'collab.chat.today': '今天',
|
||||
'collab.chat.yesterday': '昨天',
|
||||
'collab.chat.deletedMessage': '删除了一条消息',
|
||||
'collab.chat.reply': '回复',
|
||||
'collab.chat.loadMore': '加载更早的消息',
|
||||
'collab.chat.justNow': '刚刚',
|
||||
'collab.chat.minutesAgo': '{n} 分钟前',
|
||||
@@ -1335,6 +1488,204 @@ const zh: Record<string, string> = {
|
||||
'collab.polls.options': '选项',
|
||||
'collab.polls.delete': '删除',
|
||||
'collab.polls.closedSection': '已关闭',
|
||||
|
||||
// Permissions
|
||||
'admin.tabs.permissions': '权限',
|
||||
'perm.title': '权限设置',
|
||||
'perm.subtitle': '控制谁可以在应用中执行操作',
|
||||
'perm.saved': '权限设置已保存',
|
||||
'perm.resetDefaults': '恢复默认',
|
||||
'perm.customized': '已自定义',
|
||||
'perm.level.admin': '仅管理员',
|
||||
'perm.level.tripOwner': '旅行所有者',
|
||||
'perm.level.tripMember': '旅行成员',
|
||||
'perm.level.everybody': '所有人',
|
||||
'perm.cat.trip': '旅行管理',
|
||||
'perm.cat.members': '成员管理',
|
||||
'perm.cat.files': '文件',
|
||||
'perm.cat.content': '内容与日程',
|
||||
'perm.cat.extras': '预算、行李与协作',
|
||||
'perm.action.trip_create': '创建旅行',
|
||||
'perm.action.trip_edit': '编辑旅行详情',
|
||||
'perm.action.trip_delete': '删除旅行',
|
||||
'perm.action.trip_archive': '归档 / 取消归档旅行',
|
||||
'perm.action.trip_cover_upload': '上传封面图片',
|
||||
'perm.action.member_manage': '添加 / 移除成员',
|
||||
'perm.action.file_upload': '上传文件',
|
||||
'perm.action.file_edit': '编辑文件元数据',
|
||||
'perm.action.file_delete': '删除文件',
|
||||
'perm.action.place_edit': '添加 / 编辑 / 删除地点',
|
||||
'perm.action.day_edit': '编辑日程、备注与分配',
|
||||
'perm.action.reservation_edit': '管理预订',
|
||||
'perm.action.budget_edit': '管理预算',
|
||||
'perm.action.packing_edit': '管理行李清单',
|
||||
'perm.action.collab_edit': '协作(笔记、投票、聊天)',
|
||||
'perm.action.share_manage': '管理分享链接',
|
||||
'perm.actionHint.trip_create': '谁可以创建新旅行',
|
||||
'perm.actionHint.trip_edit': '谁可以更改旅行名称、日期、描述和货币',
|
||||
'perm.actionHint.trip_delete': '谁可以永久删除旅行',
|
||||
'perm.actionHint.trip_archive': '谁可以归档或取消归档旅行',
|
||||
'perm.actionHint.trip_cover_upload': '谁可以上传或更改封面图片',
|
||||
'perm.actionHint.member_manage': '谁可以邀请或移除旅行成员',
|
||||
'perm.actionHint.file_upload': '谁可以向旅行上传文件',
|
||||
'perm.actionHint.file_edit': '谁可以编辑文件描述和链接',
|
||||
'perm.actionHint.file_delete': '谁可以将文件移至回收站或永久删除',
|
||||
'perm.actionHint.place_edit': '谁可以添加、编辑或删除地点',
|
||||
'perm.actionHint.day_edit': '谁可以编辑日程、日程备注和地点分配',
|
||||
'perm.actionHint.reservation_edit': '谁可以创建、编辑或删除预订',
|
||||
'perm.actionHint.budget_edit': '谁可以创建、编辑或删除预算项目',
|
||||
'perm.actionHint.packing_edit': '谁可以管理行李物品和包袋',
|
||||
'perm.actionHint.collab_edit': '谁可以创建笔记、投票和发送消息',
|
||||
'perm.actionHint.share_manage': '谁可以创建或删除公开分享链接',
|
||||
// Undo
|
||||
'undo.button': '撤销',
|
||||
'undo.tooltip': '撤销:{action}',
|
||||
'undo.assignPlace': '地点已分配至某天',
|
||||
'undo.removeAssignment': '地点已从某天移除',
|
||||
'undo.reorder': '地点已重新排序',
|
||||
'undo.optimize': '路线已优化',
|
||||
'undo.deletePlace': '地点已删除',
|
||||
'undo.moveDay': '地点已移至另一天',
|
||||
'undo.lock': '地点锁定已切换',
|
||||
'undo.importGpx': 'GPX 导入',
|
||||
'undo.importGoogleList': 'Google 地图导入',
|
||||
|
||||
// Notifications
|
||||
'notifications.title': '通知',
|
||||
'notifications.markAllRead': '全部标为已读',
|
||||
'notifications.deleteAll': '全部删除',
|
||||
'notifications.showAll': '查看所有通知',
|
||||
'notifications.empty': '暂无通知',
|
||||
'notifications.emptyDescription': '您已全部查阅!',
|
||||
'notifications.all': '全部',
|
||||
'notifications.unreadOnly': '未读',
|
||||
'notifications.markRead': '标为已读',
|
||||
'notifications.markUnread': '标为未读',
|
||||
'notifications.delete': '删除',
|
||||
'notifications.system': '系统',
|
||||
'memories.error.loadAlbums': '加载相册失败',
|
||||
'memories.error.linkAlbum': '关联相册失败',
|
||||
'memories.error.unlinkAlbum': '取消关联相册失败',
|
||||
'memories.error.syncAlbum': '同步相册失败',
|
||||
'memories.error.loadPhotos': '加载照片失败',
|
||||
'memories.error.addPhotos': '添加照片失败',
|
||||
'memories.error.removePhoto': '删除照片失败',
|
||||
'memories.error.toggleSharing': '更新共享设置失败',
|
||||
'undo.addPlace': '地点已添加',
|
||||
'undo.done': '已撤销:{action}',
|
||||
'notifications.test.title': '来自 {actor} 的测试通知',
|
||||
'notifications.test.text': '这是一条简单的测试通知。',
|
||||
'notifications.test.booleanTitle': '{actor} 请求您的审批',
|
||||
'notifications.test.booleanText': '测试布尔通知。',
|
||||
'notifications.test.accept': '批准',
|
||||
'notifications.test.decline': '拒绝',
|
||||
'notifications.test.navigateTitle': '查看详情',
|
||||
'notifications.test.navigateText': '测试跳转通知。',
|
||||
'notifications.test.goThere': '前往',
|
||||
'notifications.test.adminTitle': '管理员广播',
|
||||
'notifications.test.adminText': '{actor} 向所有管理员发送了测试通知。',
|
||||
'notifications.test.tripTitle': '{actor} 在您的行程中发帖',
|
||||
'notifications.test.tripText': '行程"{trip}"的测试通知。',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': '行李清单',
|
||||
'todo.subtab.todo': '待办事项',
|
||||
'todo.completed': '已完成',
|
||||
'todo.filter.all': '全部',
|
||||
'todo.filter.open': '进行中',
|
||||
'todo.filter.done': '已完成',
|
||||
'todo.uncategorized': '未分类',
|
||||
'todo.namePlaceholder': '任务名称',
|
||||
'todo.descriptionPlaceholder': '描述(可选)',
|
||||
'todo.unassigned': '未分配',
|
||||
'todo.noCategory': '无分类',
|
||||
'todo.hasDescription': '有描述',
|
||||
'todo.addItem': '添加新任务...',
|
||||
'todo.newCategory': '分类名称',
|
||||
'todo.addCategory': '添加分类',
|
||||
'todo.newItem': '新任务',
|
||||
'todo.empty': '暂无任务,添加一个任务开始吧!',
|
||||
'todo.filter.my': '我的任务',
|
||||
'todo.filter.overdue': '已逾期',
|
||||
'todo.sidebar.tasks': '任务',
|
||||
'todo.sidebar.categories': '分类',
|
||||
'todo.detail.title': '任务',
|
||||
'todo.detail.description': '描述',
|
||||
'todo.detail.category': '分类',
|
||||
'todo.detail.dueDate': '截止日期',
|
||||
'todo.detail.assignedTo': '分配给',
|
||||
'todo.detail.delete': '删除',
|
||||
'todo.detail.save': '保存更改',
|
||||
'todo.detail.create': '创建任务',
|
||||
'todo.detail.priority': '优先级',
|
||||
'todo.detail.noPriority': '无',
|
||||
'todo.sortByPrio': '优先级',
|
||||
|
||||
// Notification system (added from feat/notification-system)
|
||||
'settings.notifyVersionAvailable': '有新版本可用',
|
||||
'settings.notificationPreferences.noChannels': '未配置通知渠道。请联系管理员设置电子邮件或 Webhook 通知。',
|
||||
'settings.webhookUrl.label': 'Webhook URL',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': '输入您的 Discord、Slack 或自定义 Webhook URL 以接收通知。',
|
||||
'settings.webhookUrl.save': '保存',
|
||||
'settings.webhookUrl.saved': 'Webhook URL 已保存',
|
||||
'settings.webhookUrl.test': '测试',
|
||||
'settings.webhookUrl.testSuccess': '测试 Webhook 发送成功',
|
||||
'settings.webhookUrl.testFailed': '测试 Webhook 失败',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': '应用内通知始终处于活跃状态,无法全局禁用。',
|
||||
'admin.notifications.adminWebhookPanel.title': '管理员 Webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': '此 Webhook 专用于管理员通知(如版本更新提醒)。它与用户 Webhook 相互独立,配置 URL 后自动触发。',
|
||||
'admin.notifications.adminWebhookPanel.saved': '管理员 Webhook URL 已保存',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
|
||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
|
||||
'admin.tabs.notifications': '通知',
|
||||
'notifications.versionAvailable.title': '有可用更新',
|
||||
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
|
||||
'notifications.versionAvailable.button': '查看详情',
|
||||
'notif.test.title': '[测试] 通知',
|
||||
'notif.test.simple.text': '这是一条简单的测试通知。',
|
||||
'notif.test.boolean.text': '您是否接受此测试通知?',
|
||||
'notif.test.navigate.text': '点击下方前往控制台。',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': '旅行邀请',
|
||||
'notif.trip_invite.text': '{actor} 邀请您加入 {trip}',
|
||||
'notif.booking_change.title': '预订已更新',
|
||||
'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订',
|
||||
'notif.trip_reminder.title': '旅行提醒',
|
||||
'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!',
|
||||
'notif.vacay_invite.title': 'Vacay 融合邀请',
|
||||
'notif.vacay_invite.text': '{actor} 邀请您合并假期计划',
|
||||
'notif.photos_shared.title': '照片已分享',
|
||||
'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 张照片',
|
||||
'notif.collab_message.title': '新消息',
|
||||
'notif.collab_message.text': '{actor} 在 {trip} 中发送了消息',
|
||||
'notif.packing_tagged.title': '行李分配',
|
||||
'notif.packing_tagged.text': '{actor} 将您分配到 {trip} 中的 {category}',
|
||||
'notif.version_available.title': '新版本可用',
|
||||
'notif.version_available.text': 'TREK {version} 现已可用',
|
||||
'notif.action.view_trip': '查看旅行',
|
||||
'notif.action.view_collab': '查看消息',
|
||||
'notif.action.view_packing': '查看行李',
|
||||
'notif.action.view_photos': '查看照片',
|
||||
'notif.action.view_vacay': '查看 Vacay',
|
||||
'notif.action.view_admin': '前往管理',
|
||||
'notif.action.view': '查看',
|
||||
'notif.action.accept': '接受',
|
||||
'notif.action.decline': '拒绝',
|
||||
'notif.generic.title': '通知',
|
||||
'notif.generic.text': '您有一条新通知',
|
||||
'notif.dev.unknown_event.title': '[DEV] 未知事件',
|
||||
'notif.dev.unknown_event.text': '事件类型 "{event}" 未在 EVENT_NOTIFICATION_CONFIG 中注册',
|
||||
}
|
||||
|
||||
export default zh
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+532
-242
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
|
||||
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
@@ -14,7 +16,9 @@ import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||
import AddonManager from '../components/Admin/AddonManager'
|
||||
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
import PermissionsPanel from '../components/Admin/PermissionsPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
|
||||
interface AdminUser {
|
||||
@@ -42,6 +46,7 @@ interface OidcConfig {
|
||||
client_secret_set: boolean
|
||||
display_name: string
|
||||
oidc_only: boolean
|
||||
discovery_url: string
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -52,18 +57,124 @@ interface UpdateInfo {
|
||||
is_docker?: boolean
|
||||
}
|
||||
|
||||
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
}
|
||||
|
||||
const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
}
|
||||
|
||||
function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
|
||||
const [matrix, setMatrix] = useState<any>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
|
||||
const visibleChannels = (['inapp', 'email', 'webhook'] as const).filter(ch => {
|
||||
if (!matrix.available_channels[ch]) return false
|
||||
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
|
||||
})
|
||||
|
||||
const toggle = async (eventType: string, channel: string) => {
|
||||
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||
const updated = { ...matrix.preferences, [eventType]: { ...matrix.preferences[eventType], [channel]: !current } }
|
||||
setMatrix((m: any) => m ? { ...m, preferences: updated } : m)
|
||||
setSaving(true)
|
||||
try {
|
||||
await adminApi.updateNotificationPreferences(updated)
|
||||
} catch {
|
||||
setMatrix((m: any) => m ? { ...m, preferences: matrix.preferences } : m)
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (matrix.event_types.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('settings.notificationPreferences.noChannels')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.tabs.notifications')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map((eventType: string) => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
return (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch)) {
|
||||
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||
return (
|
||||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => toggle(eventType, ch)}
|
||||
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: isOn ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode, serverTimezone } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
|
||||
const devMode = useAuthStore(s => s.devMode)
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'config', label: t('admin.tabs.config') },
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
||||
{ id: 'github', label: t('admin.tabs.github') },
|
||||
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>('users')
|
||||
@@ -80,11 +191,12 @@ export default function AdminPage(): React.ReactElement {
|
||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// OIDC config
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false })
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' })
|
||||
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
||||
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
||||
|
||||
// Invite links
|
||||
const [invites, setInvites] = useState<any[]>([])
|
||||
@@ -116,13 +228,14 @@ export default function AdminPage(): React.ReactElement {
|
||||
// Version check & update
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||
const [updating, setUpdating] = useState<boolean>(false)
|
||||
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const { user: currentUser, updateApiKeys } = useAuthStore()
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
const [showRotateJwtModal, setShowRotateJwtModal] = useState<boolean>(false)
|
||||
const [rotatingJwt, setRotatingJwt] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
loadAppConfig()
|
||||
@@ -155,6 +268,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
try {
|
||||
const config = await authApi.getAppConfig()
|
||||
setAllowRegistration(config.allow_registration)
|
||||
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
} catch (err: unknown) {
|
||||
// ignore
|
||||
@@ -171,26 +285,6 @@ export default function AdminPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstallUpdate = async () => {
|
||||
setUpdating(true)
|
||||
setUpdateResult(null)
|
||||
try {
|
||||
await adminApi.installUpdate()
|
||||
setUpdateResult('success')
|
||||
// Server is restarting — poll until it comes back, then reload
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
await authApi.getAppConfig()
|
||||
clearInterval(poll)
|
||||
window.location.reload()
|
||||
} catch { /* still restarting */ }
|
||||
}, 2000)
|
||||
} catch {
|
||||
setUpdateResult('error')
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRegistration = async (value) => {
|
||||
setAllowRegistration(value)
|
||||
try {
|
||||
@@ -201,6 +295,18 @@ export default function AdminPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRequireMfa = async (value: boolean) => {
|
||||
setRequireMfa(value)
|
||||
try {
|
||||
await authApi.updateAppSettings({ require_mfa: value })
|
||||
setAppRequireMfa(value)
|
||||
toast.success(t('common.saved'))
|
||||
} catch (err: unknown) {
|
||||
setRequireMfa(!value)
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
@@ -253,6 +359,10 @@ export default function AdminPage(): React.ReactElement {
|
||||
toast.error(t('admin.toast.fieldsRequired'))
|
||||
return
|
||||
}
|
||||
if (createForm.password.trim().length < 8) {
|
||||
toast.error(t('settings.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await adminApi.createUser(createForm)
|
||||
setUsers(prev => [data.user, ...prev])
|
||||
@@ -308,7 +418,13 @@ export default function AdminPage(): React.ReactElement {
|
||||
email: editForm.email.trim() || undefined,
|
||||
role: editForm.role,
|
||||
}
|
||||
if (editForm.password.trim()) payload.password = editForm.password.trim()
|
||||
if (editForm.password.trim()) {
|
||||
if (editForm.password.trim().length < 8) {
|
||||
toast.error(t('settings.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
payload.password = editForm.password.trim()
|
||||
}
|
||||
const data = await adminApi.updateUser(editingUser.id, payload)
|
||||
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
|
||||
setEditingUser(null)
|
||||
@@ -376,23 +492,13 @@ export default function AdminPage(): React.ReactElement {
|
||||
{t('admin.update.button')}
|
||||
</a>
|
||||
)}
|
||||
{updateInfo.is_docker ? (
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('admin.update.howTo')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('admin.update.install')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('admin.update.howTo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -618,6 +724,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && <div className="mt-6"><PermissionsPanel /></div>}
|
||||
|
||||
{/* Create Invite Modal */}
|
||||
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
|
||||
<div className="space-y-4">
|
||||
@@ -692,14 +800,38 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleRegistration(!allowRegistration)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
allowRegistration ? 'bg-slate-900' : 'bg-slate-300'
|
||||
}`}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: allowRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
allowRegistration ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: allowRegistration ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Require 2FA for all users */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.requireMfa')}</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.requireMfa')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.requireMfaHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRequireMfa(!requireMfa)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: requireMfa ? '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: requireMfa ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -869,6 +1001,17 @@ export default function AdminPage(): React.ReactElement {
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Discovery URL <span className="text-slate-400 font-normal">(optional)</span></label>
|
||||
<input
|
||||
type="url"
|
||||
value={oidcConfig.discovery_url}
|
||||
onChange={e => setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))}
|
||||
placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration'
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at <code className="bg-slate-100 px-1 rounded">{'<issuer>/.well-known/openid-configuration'}</code>.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
|
||||
<input
|
||||
@@ -896,14 +1039,12 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4 ${
|
||||
oidcConfig.oidc_only ? 'bg-slate-900' : 'bg-slate-300'
|
||||
}`}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4"
|
||||
style={{ background: oidcConfig.oidc_only ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
oidcConfig.oidc_only ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: oidcConfig.oidc_only ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -912,7 +1053,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
onClick={async () => {
|
||||
setSavingOidc(true)
|
||||
try {
|
||||
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only }
|
||||
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only, discovery_url: oidcConfig.discovery_url }
|
||||
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
||||
await adminApi.updateOidc(payload)
|
||||
toast.success(t('admin.oidcSaved'))
|
||||
@@ -930,59 +1071,246 @@ export default function AdminPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* SMTP / Notifications */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
|
||||
<h2 className="font-semibold text-red-700 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Danger Zone
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
{smtpLoaded && [
|
||||
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
||||
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
||||
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
||||
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
|
||||
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
|
||||
].map(field => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type={field.type || 'text'}
|
||||
value={smtpValues[field.key] || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Rotate JWT Secret</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Generate a new JWT signing secret. All active sessions will be invalidated immediately.</p>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={async () => {
|
||||
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
|
||||
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).catch(() => {})
|
||||
}
|
||||
try {
|
||||
const result = await notificationsApi.testSmtp()
|
||||
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRotateJwtModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Rotate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (() => {
|
||||
// Derive active channels from smtpValues.notification_channels (plural)
|
||||
// with fallback to notification_channel (singular) for existing installs
|
||||
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
|
||||
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean) => {
|
||||
const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notification_channels: chans })
|
||||
} catch {
|
||||
// Revert state on failure
|
||||
const reverted = [emailActive && 'email', webhookActive && 'webhook'].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const smtpConfigured = !!(smtpValues.smtp_host?.trim())
|
||||
const saveNotifications = async () => {
|
||||
// Saves credentials only — channel activation is auto-saved by the toggle
|
||||
const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||
const payload: Record<string, string> = {}
|
||||
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
||||
try {
|
||||
await authApi.updateAppSettings(payload)
|
||||
toast.success(t('admin.notifications.saved'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
return (<>
|
||||
<div className="space-y-4">
|
||||
{/* Email Panel */}
|
||||
<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('admin.notifications.emailPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setChannels(!emailActive, webhookActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: emailActive ? '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: emailActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`p-6 space-y-3 ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{smtpLoaded && [
|
||||
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
|
||||
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
|
||||
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
|
||||
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
|
||||
].map(field => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type={field.type || 'text'}
|
||||
value={smtpValues[field.key] || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
|
||||
</div>
|
||||
<button onClick={() => {
|
||||
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? '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: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
|
||||
<button onClick={saveNotifications}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
|
||||
<Save className="w-4 h-4" />{t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||
const payload: Record<string, string> = {}
|
||||
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
|
||||
await authApi.updateAppSettings(payload).catch(() => {})
|
||||
try {
|
||||
const result = await notificationsApi.testSmtp()
|
||||
if (result.success) toast.success(t('admin.smtp.testSuccess'))
|
||||
else toast.error(result.error || t('admin.smtp.testFailed'))
|
||||
} catch { toast.error(t('admin.smtp.testFailed')) }
|
||||
}}
|
||||
disabled={!smtpConfigured}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.webhookPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setChannels(emailActive, !webhookActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: webhookActive ? '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: webhookActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In-App Panel */}
|
||||
<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('admin.notifications.inappPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.inappPanel.hint')}</p>
|
||||
</div>
|
||||
<div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0"
|
||||
style={{ background: 'var(--text-primary)', opacity: 0.5, cursor: 'not-allowed' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: 'translateX(20px)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Webhook Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.adminWebhookPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminWebhookPanel.hint')}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
{smtpLoaded && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminWebhookPanel.title')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={smtpValues.admin_webhook_url === '••••••••' ? '' : smtpValues.admin_webhook_url || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))}
|
||||
placeholder={smtpValues.admin_webhook_url === '••••••••' ? '••••••••' : 'https://discord.com/api/webhooks/...'}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' })
|
||||
toast.success(t('admin.notifications.adminWebhookPanel.saved'))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
|
||||
<Save className="w-4 h-4" />{t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const url = smtpValues.admin_webhook_url === '••••••••' ? undefined : smtpValues.admin_webhook_url
|
||||
if (!url && smtpValues.admin_webhook_url !== '••••••••') return
|
||||
try {
|
||||
if (url) await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => {})
|
||||
const result = await notificationsApi.testWebhook(url)
|
||||
if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
|
||||
else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
|
||||
} catch { toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) }
|
||||
}}
|
||||
disabled={!smtpValues.admin_webhook_url?.trim()}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<AdminNotificationsPanel t={t} toast={toast} />
|
||||
</div>
|
||||
</>)
|
||||
})()}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'audit' && <AuditLogPanel />}
|
||||
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
|
||||
|
||||
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
|
||||
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1122,170 +1450,132 @@ export default function AdminPage(): React.ReactElement {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Update confirmation popup — matches backup restore style */}
|
||||
{/* Update instructions popup */}
|
||||
{showUpdateModal && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => { if (!updating) setShowUpdateModal(false) }}
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{updateResult === 'success' ? (
|
||||
<>
|
||||
<div style={{ background: 'linear-gradient(135deg, #16a34a, #15803d)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<CheckCircle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.success')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '20px 24px', textAlign: 'center' }}>
|
||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2" style={{ color: 'var(--text-muted)' }} />
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('admin.update.reloadHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
) : updateResult === 'error' ? (
|
||||
<>
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<XCircle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.failed')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<button
|
||||
onClick={() => { setShowUpdateModal(false); setUpdateResult(null) }}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Red header */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.confirmTitle')}</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
v{updateInfo?.current} → v{updateInfo?.latest}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: 'linear-gradient(135deg, #0f172a, #1e293b)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<ArrowUpCircle size={20} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.howTo')}</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
v{updateInfo?.current} → v{updateInfo?.latest}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
{updateInfo?.is_docker ? (
|
||||
<>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)}
|
||||
</p>
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
>
|
||||
{`docker pull mauriceboe/nomad:latest
|
||||
docker stop nomad && docker rm nomad
|
||||
docker run -d --name nomad \\
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
>
|
||||
{`docker pull mauriceboe/trek:latest
|
||||
docker stop trek && docker rm trek
|
||||
docker run -d --name trek \\
|
||||
-p 3000:3000 \\
|
||||
-v /opt/nomad/data:/app/data \\
|
||||
-v /opt/nomad/uploads:/app/uploads \\
|
||||
-v /opt/trek/data:/app/data \\
|
||||
-v /opt/trek/uploads:/app/uploads \\
|
||||
--restart unless-stopped \\
|
||||
mauriceboe/nomad:latest`}
|
||||
</div>
|
||||
mauriceboe/trek:latest`}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.dataInfo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.dataInfo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Download className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
{t('admin.update.backupHint')}{' '}
|
||||
<button
|
||||
onClick={() => { setShowUpdateModal(false); setActiveTab('backup') }}
|
||||
className="underline font-semibold hover:text-blue-950 dark:hover:text-blue-100"
|
||||
>{t('admin.update.backupLink')}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.warning')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.dataInfo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
disabled={updating}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{!updateInfo?.is_docker && (
|
||||
<button
|
||||
onClick={handleInstallUpdate}
|
||||
disabled={updating}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200 disabled:opacity-60 flex items-center gap-2"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{updating ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{updating ? t('admin.update.installing') : t('admin.update.confirm')}
|
||||
</button>
|
||||
)}
|
||||
{updateInfo?.release_url && (
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<ExternalLink className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<a href={updateInfo.release_url} target="_blank" rel="noopener noreferrer" className="underline font-semibold">
|
||||
{t('admin.update.button')}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rotate JWT Secret confirmation modal */}
|
||||
<Modal
|
||||
isOpen={showRotateJwtModal}
|
||||
onClose={() => setShowRotateJwtModal(false)}
|
||||
title="Rotate JWT Secret"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowRotateJwtModal(false)}
|
||||
disabled={rotatingJwt}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setRotatingJwt(true)
|
||||
try {
|
||||
await adminApi.rotateJwtSecret()
|
||||
setShowRotateJwtModal(false)
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
setRotatingJwt(false)
|
||||
}
|
||||
}}
|
||||
disabled={rotatingJwt}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white rounded-lg font-medium"
|
||||
>
|
||||
{rotatingJwt ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Rotate & Log out
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 mb-1">Warning, this will invalidate all sessions and log you out.</p>
|
||||
<p className="text-xs text-slate-500">A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+515
-24
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -127,6 +127,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const glareRef = useRef<HTMLDivElement>(null)
|
||||
const borderGlareRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
|
||||
|
||||
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||
@@ -139,7 +140,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
// Border glow that follows cursor
|
||||
borderGlareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
borderGlareRef.current.style.WebkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
}
|
||||
const handlePanelMouseLeave = () => {
|
||||
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||
@@ -153,7 +154,16 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
||||
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
|
||||
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
|
||||
const regionLayerRef = useRef<L.GeoJSON | null>(null)
|
||||
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
|
||||
const [showRegions, setShowRegions] = useState(false)
|
||||
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
|
||||
const regionTooltipRef = useRef<HTMLDivElement>(null)
|
||||
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
|
||||
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
|
||||
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null)
|
||||
const [bucketMonth, setBucketMonth] = useState(0)
|
||||
const [bucketYear, setBucketYear] = useState(0)
|
||||
|
||||
@@ -170,6 +180,26 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
|
||||
const bucketMarkersRef = useRef<any>(null)
|
||||
|
||||
const [atlas_country_search, set_atlas_country_search] = useState('')
|
||||
const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([])
|
||||
const [atlas_country_open, set_atlas_country_open] = useState(false)
|
||||
|
||||
const atlas_country_options = useMemo(() => {
|
||||
if (!geoData) return []
|
||||
const opts: { code: string; label: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const f of (geoData as any).features || []) {
|
||||
const a2 = f?.properties?.ISO_A2
|
||||
if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue
|
||||
if (seen.has(a2)) continue
|
||||
seen.add(a2)
|
||||
const label = String(resolveName(a2) || f?.properties?.NAME || f?.properties?.ADMIN || a2)
|
||||
opts.push({ code: a2, label })
|
||||
}
|
||||
opts.sort((a, b) => a.label.localeCompare(b.label))
|
||||
return opts
|
||||
}, [geoData, resolveName])
|
||||
|
||||
// Load atlas data + bucket list
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -200,6 +230,41 @@ export default function AtlasPage(): React.ReactElement {
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load visited regions (geocoded from places/trips) — once on mount
|
||||
useEffect(() => {
|
||||
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
|
||||
.then(r => setVisitedRegions(r.data?.regions || {}))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load admin-1 GeoJSON for countries visible in the current viewport
|
||||
const loadRegionsForViewportRef = useRef<() => void>(() => {})
|
||||
const loadRegionsForViewport = (): void => {
|
||||
if (!mapInstance.current) return
|
||||
const bounds = mapInstance.current.getBounds()
|
||||
const toLoad: string[] = []
|
||||
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
|
||||
if (regionGeoCache.current[code]) continue
|
||||
try {
|
||||
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
|
||||
} catch {}
|
||||
}
|
||||
if (!toLoad.length) return
|
||||
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
|
||||
.then(geoRes => {
|
||||
const geo = geoRes.data
|
||||
if (!geo?.features) return
|
||||
let added = false
|
||||
for (const c of toLoad) {
|
||||
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
|
||||
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
|
||||
}
|
||||
if (added) setRegionGeoLoaded(v => v + 1)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
loadRegionsForViewportRef.current = loadRegionsForViewport
|
||||
|
||||
// Initialize map — runs after loading is done and mapRef is available
|
||||
useEffect(() => {
|
||||
if (loading || !mapRef.current) return
|
||||
@@ -209,7 +274,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
center: [25, 0],
|
||||
zoom: 3,
|
||||
minZoom: 3,
|
||||
maxZoom: 7,
|
||||
maxZoom: 10,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
maxBounds: [[-90, -220], [90, 220]],
|
||||
@@ -225,26 +290,60 @@ export default function AtlasPage(): React.ReactElement {
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 8,
|
||||
maxZoom: 10,
|
||||
keepBuffer: 25,
|
||||
updateWhenZooming: true,
|
||||
updateWhenIdle: false,
|
||||
tileSize: 256,
|
||||
zoomOffset: 0,
|
||||
crossOrigin: true,
|
||||
loading: true,
|
||||
crossOrigin: true
|
||||
}).addTo(map)
|
||||
|
||||
// Preload adjacent zoom level tiles
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 8,
|
||||
maxZoom: 10,
|
||||
keepBuffer: 10,
|
||||
opacity: 0,
|
||||
tileSize: 256,
|
||||
crossOrigin: true,
|
||||
}).addTo(map)
|
||||
|
||||
// Custom pane for region layer — above overlay (z-index 400)
|
||||
map.createPane('regionPane')
|
||||
map.getPane('regionPane')!.style.zIndex = '401'
|
||||
|
||||
mapInstance.current = map
|
||||
|
||||
// Zoom-based region switching
|
||||
map.on('zoomend', () => {
|
||||
const z = map.getZoom()
|
||||
const shouldShow = z >= 5
|
||||
setShowRegions(shouldShow)
|
||||
const overlayPane = map.getPane('overlayPane')
|
||||
if (overlayPane) {
|
||||
overlayPane.style.opacity = shouldShow ? '0.35' : '1'
|
||||
overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto'
|
||||
}
|
||||
if (shouldShow) {
|
||||
// Re-add region layer if it was removed while zoomed out
|
||||
if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) {
|
||||
regionLayerRef.current.addTo(map)
|
||||
}
|
||||
loadRegionsForViewportRef.current()
|
||||
} else {
|
||||
// Physically remove region layer so its SVG paths can't intercept events
|
||||
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
|
||||
if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) {
|
||||
regionLayerRef.current.resetStyle()
|
||||
regionLayerRef.current.removeFrom(map)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
map.on('moveend', () => {
|
||||
if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
|
||||
})
|
||||
|
||||
return () => { map.remove(); mapInstance.current = null }
|
||||
}, [dark, loading])
|
||||
|
||||
@@ -292,6 +391,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
||||
const c = countryMap[a3]
|
||||
if (c) {
|
||||
country_layer_by_a2_ref.current[c.code] = layer
|
||||
const name = resolveName(c.code)
|
||||
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
|
||||
const tooltipHtml = `
|
||||
@@ -318,10 +418,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
})
|
||||
layer.on('click', () => {
|
||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||
// Manually marked only — show unmark popup
|
||||
handleUnmarkCountry(c.code)
|
||||
} else {
|
||||
loadCountryDetail(c.code)
|
||||
}
|
||||
})
|
||||
layer.on('mouseover', (e) => {
|
||||
@@ -337,6 +434,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const isoA2 = feature.properties?.ISO_A2
|
||||
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
|
||||
if (countryCode && countryCode !== '-99') {
|
||||
country_layer_by_a2_ref.current[countryCode] = layer
|
||||
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
@@ -357,15 +455,176 @@ export default function AtlasPage(): React.ReactElement {
|
||||
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
|
||||
}, [geoData, data, dark])
|
||||
|
||||
// Render sub-national region layer (zoom >= 5)
|
||||
useEffect(() => {
|
||||
if (!mapInstance.current) return
|
||||
|
||||
// Remove existing region layer
|
||||
if (regionLayerRef.current) {
|
||||
mapInstance.current.removeLayer(regionLayerRef.current)
|
||||
regionLayerRef.current = null
|
||||
}
|
||||
|
||||
if (Object.keys(regionGeoCache.current).length === 0) return
|
||||
|
||||
// Build set of visited region codes first
|
||||
const visitedRegionCodes = new Set<string>()
|
||||
const visitedRegionNames = new Set<string>()
|
||||
const regionPlaceCounts: Record<string, number> = {}
|
||||
for (const [, regions] of Object.entries(visitedRegions)) {
|
||||
for (const r of regions) {
|
||||
visitedRegionCodes.add(r.code)
|
||||
visitedRegionNames.add(r.name.toLowerCase())
|
||||
regionPlaceCounts[r.code] = r.placeCount
|
||||
regionPlaceCounts[r.name.toLowerCase()] = r.placeCount
|
||||
}
|
||||
}
|
||||
|
||||
// Match feature by ISO code OR region name
|
||||
const isVisitedFeature = (f: any) => {
|
||||
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
|
||||
const name = (f.properties?.name || '').toLowerCase()
|
||||
if (visitedRegionNames.has(name)) return true
|
||||
// Fuzzy: check if any visited name is contained in feature name or vice versa
|
||||
for (const vn of visitedRegionNames) {
|
||||
if (name.includes(vn) || vn.includes(name)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Include ALL region features — visited ones get colored fill, unvisited get outline only
|
||||
const allFeatures: any[] = []
|
||||
for (const geo of Object.values(regionGeoCache.current)) {
|
||||
for (const f of geo.features) {
|
||||
allFeatures.push(f)
|
||||
}
|
||||
}
|
||||
if (allFeatures.length === 0) return
|
||||
|
||||
// Use same colors as country layer
|
||||
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
|
||||
const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : []
|
||||
const countryColorMap: Record<string, string> = {}
|
||||
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
|
||||
// Map country A2 code to country color
|
||||
const a2ColorMap: Record<string, string> = {}
|
||||
if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
|
||||
|
||||
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
|
||||
|
||||
const svgRenderer = L.svg({ pane: 'regionPane' })
|
||||
|
||||
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
|
||||
renderer: svgRenderer,
|
||||
interactive: true,
|
||||
pane: 'regionPane',
|
||||
style: (feature) => {
|
||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||
const visited = isVisitedFeature(feature)
|
||||
return visited ? {
|
||||
fillColor: a2ColorMap[countryA2] || '#6366f1',
|
||||
fillOpacity: 0.85,
|
||||
color: dark ? '#888' : '#64748b',
|
||||
weight: 1.2,
|
||||
} : {
|
||||
fillColor: dark ? '#ffffff' : '#000000',
|
||||
fillOpacity: 0.03,
|
||||
color: dark ? '#555' : '#94a3b8',
|
||||
weight: 1,
|
||||
}
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
const regionName = feature?.properties?.name || ''
|
||||
const countryName = feature?.properties?.admin || ''
|
||||
const regionCode = feature?.properties?.iso_3166_2 || ''
|
||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||
const visited = isVisitedFeature(feature)
|
||||
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0
|
||||
layer.on('click', () => {
|
||||
if (!countryA2) return
|
||||
if (visited) {
|
||||
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
|
||||
if (regionEntry?.manuallyMarked) {
|
||||
setConfirmActionRef.current({
|
||||
type: 'unmark-region',
|
||||
code: countryA2,
|
||||
name: regionName,
|
||||
regionCode,
|
||||
countryName,
|
||||
})
|
||||
} else {
|
||||
loadCountryDetailRef.current(countryA2)
|
||||
}
|
||||
} else {
|
||||
setConfirmActionRef.current({
|
||||
type: 'choose-region',
|
||||
code: countryA2, // country A2 code — used for flag display
|
||||
name: regionName, // region name — shown as heading
|
||||
regionCode,
|
||||
countryName,
|
||||
})
|
||||
}
|
||||
})
|
||||
layer.on('mouseover', (e: any) => {
|
||||
e.target.setStyle(visited
|
||||
? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }
|
||||
: { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' }
|
||||
)
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) {
|
||||
tt.style.display = 'block'
|
||||
tt.style.left = e.originalEvent.clientX + 12 + 'px'
|
||||
tt.style.top = e.originalEvent.clientY - 10 + 'px'
|
||||
tt.innerHTML = visited
|
||||
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
|
||||
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
|
||||
}
|
||||
})
|
||||
layer.on('mousemove', (e: any) => {
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' }
|
||||
})
|
||||
layer.on('mouseout', (e: any) => {
|
||||
regionLayerRef.current?.resetStyle(e.target)
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) tt.style.display = 'none'
|
||||
})
|
||||
},
|
||||
})
|
||||
// Only add to map if currently in region mode — otherwise hold it ready for when user zooms in
|
||||
if (mapInstance.current.getZoom() >= 6) {
|
||||
regionLayerRef.current.addTo(mapInstance.current)
|
||||
}
|
||||
}, [regionGeoLoaded, visitedRegions, dark, t])
|
||||
|
||||
const handleMarkCountry = (code: string, name: string): void => {
|
||||
setConfirmAction({ type: 'choose', code, name })
|
||||
}
|
||||
handleMarkCountryRef.current = handleMarkCountry
|
||||
setConfirmActionRef.current = setConfirmAction
|
||||
|
||||
const handleUnmarkCountry = (code: string): void => {
|
||||
const country = data?.countries.find(c => c.code === code)
|
||||
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
|
||||
}
|
||||
|
||||
const select_country_from_search = (country_code: string): void => {
|
||||
const country_label = resolveName(country_code)
|
||||
set_atlas_country_search(country_label)
|
||||
set_atlas_country_open(false)
|
||||
set_atlas_country_results([])
|
||||
|
||||
const layer = country_layer_by_a2_ref.current[country_code]
|
||||
try {
|
||||
if (layer?.getBounds && mapInstance.current) {
|
||||
mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 })
|
||||
}
|
||||
} catch (e ) {
|
||||
console.error('Error fitting bounds', e)
|
||||
}
|
||||
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
||||
}
|
||||
|
||||
const executeConfirmAction = async (): Promise<void> => {
|
||||
if (!confirmAction) return
|
||||
const { type, code } = confirmAction
|
||||
@@ -396,6 +655,12 @@ export default function AtlasPage(): React.ReactElement {
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
}
|
||||
})
|
||||
setVisitedRegions(prev => {
|
||||
if (!prev[code]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[code]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +738,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
setCountryDetail(r.data)
|
||||
} catch { /* */ }
|
||||
}
|
||||
loadCountryDetailRef.current = loadCountryDetail
|
||||
|
||||
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
||||
const countries = data?.countries || []
|
||||
@@ -495,6 +761,141 @@ export default function AtlasPage(): React.ReactElement {
|
||||
{/* Map */}
|
||||
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||
|
||||
{/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */}
|
||||
<div ref={regionTooltipRef} style={{
|
||||
position: 'fixed', display: 'none',
|
||||
zIndex: 9999, pointerEvents: 'none',
|
||||
background: dark ? 'rgba(15,15,20,0.92)' : 'rgba(255,255,255,0.96)',
|
||||
color: dark ? '#fff' : '#111',
|
||||
borderRadius: 10, padding: '10px 14px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`,
|
||||
fontSize: 12, minWidth: 120,
|
||||
}} />
|
||||
<div
|
||||
className="absolute z-20 flex justify-center"
|
||||
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 16,
|
||||
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
|
||||
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(18px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
|
||||
boxShadow: dark ? '0 8px 26px rgba(0,0,0,0.25)' : '0 8px 26px rgba(0,0,0,0.10)',
|
||||
}}>
|
||||
<Search size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<input
|
||||
value={atlas_country_search}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
set_atlas_country_search(raw)
|
||||
const q = raw.trim().toLowerCase()
|
||||
if (!q) {
|
||||
set_atlas_country_results([])
|
||||
set_atlas_country_open(false)
|
||||
return
|
||||
}
|
||||
const results = atlas_country_options
|
||||
.filter(o => o.label.toLowerCase().includes(q) || o.code.toLowerCase() === q)
|
||||
.slice(0, 8)
|
||||
set_atlas_country_results(results)
|
||||
set_atlas_country_open(true)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (atlas_country_results.length > 0) set_atlas_country_open(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
set_atlas_country_open(false)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
const first = atlas_country_results[0]
|
||||
if (first) select_country_from_search(first.code)
|
||||
}
|
||||
}}
|
||||
placeholder={t('atlas.searchCountry')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: 13,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
{atlas_country_search.trim() && (
|
||||
<button
|
||||
onClick={() => {
|
||||
set_atlas_country_search('')
|
||||
set_atlas_country_results([])
|
||||
set_atlas_country_open(false)
|
||||
}}
|
||||
style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}
|
||||
aria-label="Clear"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{atlas_country_open && atlas_country_results.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
|
||||
background: dark ? 'rgba(10,10,15,0.75)' : 'rgba(255,255,255,0.75)',
|
||||
backdropFilter: 'blur(18px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
|
||||
boxShadow: dark ? '0 12px 30px rgba(0,0,0,0.35)' : '0 12px 30px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
onMouseLeave={() => set_atlas_country_open(false)}
|
||||
>
|
||||
{atlas_country_results.map((r) => (
|
||||
<button
|
||||
key={r.code}
|
||||
onClick={() => select_country_from_search(r.code)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'left',
|
||||
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<img src={`https://flagcdn.com/w40/${r.code.toLowerCase()}.png`} alt={r.code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 650, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.label}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Bottom bar */}
|
||||
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}>
|
||||
<div className="flex items-center gap-4 px-5 py-4 rounded-2xl"
|
||||
@@ -551,7 +952,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
bucketForm={bucketForm} setBucketForm={setBucketForm}
|
||||
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
|
||||
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
|
||||
bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
||||
bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
||||
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
|
||||
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
|
||||
t={t} dark={dark}
|
||||
@@ -607,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'choose-region' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{confirmAction.countryName && (
|
||||
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||
)}
|
||||
<button onClick={async () => {
|
||||
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
|
||||
setVisitedRegions(prev => {
|
||||
const existing = prev[countryCode] || []
|
||||
if (existing.find(r => r.code === rCode)) return prev
|
||||
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
|
||||
})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||
})
|
||||
} catch {}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark' && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
|
||||
@@ -623,37 +1068,82 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<>
|
||||
{confirmAction.countryName && (
|
||||
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||
)}
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmarkRegion')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button onClick={() => setConfirmAction(null)}
|
||||
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={async () => {
|
||||
const { code: countryCode, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
|
||||
setVisitedRegions(prev => {
|
||||
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
|
||||
const next = { ...prev, [countryCode]: remaining }
|
||||
if (remaining.length === 0) delete next[countryCode]
|
||||
return next
|
||||
})
|
||||
// If no manually-marked regions remain, also remove country if it has no trips/places
|
||||
setData(prev => {
|
||||
if (!prev) return prev
|
||||
const c = prev.countries.find(c => c.code === countryCode)
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||
if (remainingRegions.length > 0) return prev
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
|
||||
{t('atlas.unmark')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect
|
||||
value={bucketMonth}
|
||||
value={String(bucketMonth)}
|
||||
onChange={v => setBucketMonth(Number(v))}
|
||||
placeholder={t('atlas.month')}
|
||||
options={[
|
||||
{ value: 0, label: '—' },
|
||||
...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
||||
{ value: '0', label: '—' },
|
||||
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect
|
||||
value={bucketYear}
|
||||
value={String(bucketYear)}
|
||||
onChange={v => setBucketYear(Number(v))}
|
||||
placeholder={t('atlas.year')}
|
||||
options={[
|
||||
{ value: 0, label: '—' },
|
||||
...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })),
|
||||
{ value: '0', label: '—' },
|
||||
...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
|
||||
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.back')}
|
||||
</button>
|
||||
@@ -717,6 +1207,7 @@ interface SidebarContentProps {
|
||||
onSearchBucket: () => Promise<void>
|
||||
onSelectBucketPoi: (result: any) => void
|
||||
bucketSearchResults: any[]
|
||||
setBucketSearchResults: (v: string[]) => void
|
||||
bucketPoiMonth: number
|
||||
setBucketPoiMonth: (v: number) => void
|
||||
bucketPoiYear: number
|
||||
@@ -728,7 +1219,7 @@ interface SidebarContentProps {
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||
@@ -854,12 +1345,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
{/* Month / Year with CustomSelect */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect value={bucketPoiMonth} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
||||
options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
||||
<CustomSelect value={String(bucketPoiMonth)} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
||||
options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect value={bucketPoiYear} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
||||
options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} />
|
||||
<CustomSelect value={String(bucketPoiYear)} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
||||
options={[{ value: '0', label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) }))]} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user